1use super::*;
2
3impl Workspace {
4 pub fn triage_list(&self) -> Result<Value> {
7 self.require_workspace()?;
8 let triage_dir = self.root.join("triage");
9 let mut items = Vec::new();
10 if triage_dir.exists() {
11 let mut paths: Vec<PathBuf> = read_dir(&triage_dir, "read triage directory")?
12 .into_iter()
13 .map(|entry| entry.path())
14 .filter(|path| path.extension().and_then(|s| s.to_str()) == Some("md"))
15 .collect();
16 paths.sort();
17 for path in paths {
18 let message_id = path
19 .file_stem()
20 .map(|stem| stem.to_string_lossy().to_string())
21 .unwrap_or_default();
22 items.push(json!({"message_id": message_id}));
23 }
24 }
25 let push_status = serde_json::to_value(crate::push_queue::push_status(&self.root)?)
26 .map_err(|e| AppError::json("serialize push status", &e))?;
27 Ok(json!({
28 "code": "triage_list",
29 "count": items.len(),
30 "push_status": push_status,
31 "path_templates": {
32 "view_path": "triage/{message_id}.md",
33 "json_path": "messages/{message_id}.json",
34 },
35 "items": items,
36 }))
37 }
38
39 pub fn refresh_triage_views(&self) -> Result<Value> {
40 self.require_workspace()?;
41 create_dir_all(&self.root.join("triage"))?;
42 let cases = CaseIndex::build(self)?;
43 let contact_map = self.contact_email_map()?;
44 let config = MailConfig::load(&self.root)?;
45 let mut desired = BTreeSet::new();
46 let mut written_count = 0usize;
47 for path in message_json_paths(&self.root)? {
48 let mut message = read_message(&path)?;
49 self.apply_materialized_workspace_overlays(&mut message)?;
50 self.apply_identity_match(&mut message, &config)?;
51 self.apply_contact_link(&mut message, &contact_map);
52 message.workspace.remote_sync = None;
53 self.write_message_materialized_cache(&message)?;
54 if self.triage_candidate(&message, &cases)? {
55 desired.insert(message.message_id.clone());
56 self.write_triage_view(&message)?;
57 written_count += 1;
58 }
59 }
60 let stale_count = self.remove_stale_triage_views(&desired)?;
61 Ok(json!({
62 "code": "triage_refreshed",
63 "triage_count": desired.len(),
64 "triage_written_count": written_count,
65 "stale_triage_removed_count": stale_count
66 }))
67 }
68}
69
70pub(crate) fn render_triage_view(
71 root: &Path,
72 language: TemplateLanguage,
73 message: &MessageFile,
74 conversation: &str,
75 suggested_case_uids: Vec<String>,
76 suggested_reason: Option<String>,
77 related_messages: Vec<Value>,
78) -> Result<String> {
79 let generated_rfc3339 = now_rfc3339();
80 let suggested_reason = suggested_reason.as_deref().unwrap_or("");
81 let suggested_reason_yaml = if suggested_reason.is_empty() {
82 String::new()
83 } else {
84 yaml_double_quote(suggested_reason)
85 };
86 let view_suggested_case_uids = suggested_case_uids.clone();
87 render_template(
88 root,
89 language,
90 TemplateKey::TriageView,
91 &json!({
92 "frontmatter": {
93 "kind": "triage_view",
94 "message_id": message.message_id.as_str(),
95 "message_ids": [message.message_id.as_str()],
96 "generated_rfc3339": generated_rfc3339.as_str(),
97 "message_count": 1,
98 "attachment_count": message.attachments.len(),
99 "suggested_case_uids": &suggested_case_uids,
100 "suggested_reason": suggested_reason,
101 "suggested_reason_yaml": suggested_reason_yaml,
102 },
103 "message": message_template_value(message)?,
104 "view": {
105 "language": language.as_str(),
106 "title": message.subject.as_deref().unwrap_or(""),
107 "generated_rfc3339": generated_rfc3339.as_str(),
108 "attachment_count": message.attachments.len(),
109 "suggested_case_uids": view_suggested_case_uids,
110 "suggested_reason": suggested_reason,
111 "related_messages": related_messages,
112 "conversation": conversation.trim(),
113 },
114 }),
115 )
116}
117
118pub(super) fn yaml_double_quote(value: &str) -> String {
119 value.replace('\\', "\\\\").replace('"', "\\\"")
120}
121
122impl Workspace {
123 pub(super) fn refresh_all_case_message_views(&self) -> Result<usize> {
124 let language = self.template_language()?;
125 let mut renderer = MarkdownTemplateRenderer::new(&self.root, language);
126 let mut count = 0usize;
127 for (_, case_path) in self.all_case_entries()? {
128 self.refresh_case_message_views_with_renderer(&case_path, &mut renderer)?;
129 count += 1;
130 }
131 Ok(count)
132 }
133
134 pub(super) fn refresh_case_message_views(&self, case_path: &Path) -> Result<()> {
135 let language = self.template_language()?;
136 let mut renderer = MarkdownTemplateRenderer::new(&self.root, language);
137 self.refresh_case_message_views_with_renderer(case_path, &mut renderer)?;
138 Ok(())
139 }
140
141 pub(super) fn refresh_case_message_views_with_renderer(
142 &self,
143 case_path: &Path,
144 renderer: &mut MarkdownTemplateRenderer<'_>,
145 ) -> Result<CaseViewRefresh> {
146 if !case_json_path(case_path).is_file() {
147 return Ok(CaseViewRefresh::default());
148 }
149 let case_fm = read_case_file(case_path)?;
150 let case_uid = case_fm.collection_uid.clone();
151 let messages_dir = case_views_messages_dir(case_path);
152 create_dir_all(&messages_dir)?;
153 let config = MailConfig::load(&self.root)?;
154 let mut desired = BTreeSet::new();
155 let mut case_conversation = Vec::new();
156 let mut messages = Vec::new();
157 let mut message_count = 0usize;
158 for message_id in case_fm.message_ids() {
159 let message = self.read_message_by_id(&message_id)?;
160 desired.insert(message_id.clone());
161 case_conversation.push(self.message_conversation_with_renderer(
162 &message,
163 &config,
164 renderer,
165 Some(case_path),
166 )?);
167 let view_path = case_message_view_path(case_path, &message_id);
168 let view = self.render_case_message_view(
169 &case_uid,
170 &case_fm.collection_name,
171 &message,
172 renderer,
173 view_path.parent(),
174 )?;
175 write_string(&view_path, &view)?;
176 messages.push(message);
177 message_count += 1;
178 }
179 self.remove_stale_case_message_views(&messages_dir, &desired)?;
180 let case_doc = self.render_case_document(
181 &case_fm,
182 &messages,
183 &case_conversation.join("\n\n"),
184 &config,
185 renderer,
186 )?;
187 write_string(&case_path.join("case.md"), &case_doc)?;
188 Ok(CaseViewRefresh {
189 case_index_count: 1,
190 case_message_count: message_count,
191 })
192 }
193
194 pub(super) fn render_case_message_view(
195 &self,
196 case_uid: &str,
197 case_name: &str,
198 message: &MessageFile,
199 renderer: &mut MarkdownTemplateRenderer<'_>,
200 output_dir: Option<&Path>,
201 ) -> Result<String> {
202 let config = MailConfig::load(&self.root)?;
203 let title = message.subject.as_deref().unwrap_or("");
204 let generated_rfc3339 = now_rfc3339();
205 let message_value = message_template_value(message)?;
206 let conversation =
207 self.message_conversation_with_renderer(message, &config, renderer, output_dir)?;
208 let context = json!({
209 "frontmatter": {
210 "kind": "case_message",
211 "case_uid": case_uid,
212 "case_name": case_name,
213 "message_id": message.message_id.as_str(),
214 "generated_rfc3339": generated_rfc3339.as_str(),
215 },
216 "case": {
217 "case_uid": case_uid,
218 "case_name": case_name,
219 },
220 "message": message_value,
221 "view": {
222 "language": config.resolved_language_bcp47(),
223 "title": title,
224 "generated_rfc3339": generated_rfc3339.as_str(),
225 "conversation": conversation.trim(),
226 },
227 });
228 renderer.render(TemplateKey::CaseMessage, &context)
229 }
230
231 pub(super) fn render_case_document(
232 &self,
233 case_fm: &CaseFrontmatter,
234 messages: &[MessageFile],
235 conversation: &str,
236 config: &MailConfig,
237 renderer: &mut MarkdownTemplateRenderer<'_>,
238 ) -> Result<String> {
239 let case_uid = case_fm.collection_uid.as_str();
240 let mut case_view = case_fm.clone();
241 case_view.message_count = messages.len();
242 case_view.attachment_count = messages
243 .iter()
244 .map(|message| message.attachments.len())
245 .sum::<usize>();
246 case_view.last_message_rfc3339 = messages
247 .iter()
248 .filter_map(message_time)
249 .max_by(|a, b| compare_rfc3339_asc(a, b));
250 let mut sorted = messages.to_vec();
251 sorted.sort_by(compare_message_time_asc);
252 let mut items = Vec::new();
253 let offset = config.resolved_timezone_offset();
254 for message in &sorted {
255 let mut fields = Vec::new();
256 let display_time = message_time_datetime(message, &offset).unwrap_or_default();
257 let display_from = message
258 .from
259 .as_deref()
260 .map(markdown_inline)
261 .unwrap_or_default();
262 let display_to = markdown_inline(&message.to.join(", "));
263 let display_subject = message
264 .subject
265 .as_deref()
266 .map(markdown_inline)
267 .unwrap_or_default();
268 let display_status = markdown_inline(&message.workspace.status);
269 if !display_time.is_empty() {
270 fields.push(json!({"kind": "time", "value": display_time.as_str()}));
271 }
272 if !display_from.is_empty() {
273 fields.push(json!({"kind": "from", "value": display_from.as_str()}));
274 }
275 if !display_to.is_empty() {
276 fields.push(json!({"kind": "to", "value": display_to.as_str()}));
277 }
278 if !display_subject.is_empty() {
279 fields.push(json!({"kind": "subject", "value": display_subject.as_str()}));
280 }
281 if !display_status.is_empty() {
282 fields.push(json!({"kind": "status", "value": display_status.as_str()}));
283 }
284 let title = message
285 .subject
286 .as_deref()
287 .filter(|value| !value.trim().is_empty())
288 .unwrap_or(message.message_id.as_str())
289 .to_string();
290 let mut item = thread_item_common(
291 message,
292 &offset,
293 config.template_language(),
294 format!("views/messages/{}.md", message.message_id),
295 title,
296 )?;
297 if let Value::Object(map) = &mut item {
298 if let Some(Value::Object(view)) = map.get_mut("view") {
299 view.insert("fields".to_string(), json!(fields));
300 }
301 }
302 items.push(item);
303 }
304 let case_value = serde_json::to_value(&case_view)
305 .map_err(|e| AppError::json("serialize case frontmatter", &e))?;
306 let generated_rfc3339 = now_rfc3339();
307 let messages_value = sorted
308 .iter()
309 .map(message_template_value)
310 .collect::<Result<Vec<_>>>()?;
311 let context = json!({
312 "frontmatter": {
313 "kind": "case_index",
314 "case_uid": case_uid,
315 "case_name": case_fm.collection_name.as_str(),
316 "generated_rfc3339": generated_rfc3339.as_str(),
317 "message_count": items.len(),
318 },
319 "case": case_value,
320 "items": items,
321 "messages": messages_value,
322 "view": {
323 "language": config.resolved_language_bcp47(),
324 "title": case_fm.collection_name.as_str(),
325 "status": case_fm.status.as_str(),
326 "message_count": items.len(),
327 "generated_rfc3339": generated_rfc3339.as_str(),
328 "conversation": conversation.trim(),
329 },
330 });
331 renderer.render(TemplateKey::CaseDocument, &context)
332 }
333
334 pub(super) fn remove_stale_case_message_views(
335 &self,
336 messages_dir: &Path,
337 desired: &BTreeSet<String>,
338 ) -> Result<()> {
339 for entry in read_dir(messages_dir, "read case message views")? {
340 let path = entry.path();
341 if path.extension().and_then(|s| s.to_str()) != Some("md") {
342 continue;
343 }
344 let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
345 continue;
346 };
347 if !desired.contains(stem) {
348 remove_file(&path)?;
349 }
350 }
351 Ok(())
352 }
353
354 pub(super) fn triage_candidate(
355 &self,
356 message: &MessageFile,
357 cases: &CaseIndex,
358 ) -> Result<bool> {
359 let current = MessageStatus::parse(&message.workspace.status)?;
360 if current.is_terminal_local() {
361 return Ok(false);
362 }
363 if message.workspace.archive_uid.is_some() || current == MessageStatus::Archived {
364 return Ok(false);
365 }
366 if message.workspace.origin.is_some() {
367 return Ok(false);
368 }
369 Ok(!cases.has_any_reference(&message.message_id))
370 }
371
372 pub(super) fn write_triage_view(&self, message: &MessageFile) -> Result<()> {
373 let path = self
374 .root
375 .join("triage")
376 .join(format!("{}.md", message.message_id));
377 let conversation = self.message_conversation_for_dir(message, path.parent())?;
378 let (suggested_case_uids, suggested_reason) = existing_triage_suggestion(&path)?;
379 let related_message_ids = self.related_message_ids(&message.message_id)?;
380 let related_messages = self.related_message_rows(&related_message_ids)?;
381 let config = MailConfig::load(&self.root)?;
382 let rendered = render_triage_view(
383 &self.root,
384 config.template_language(),
385 message,
386 &conversation,
387 suggested_case_uids,
388 suggested_reason,
389 related_messages,
390 )?;
391 write_string(&path, &rendered)
392 }
393
394 pub(super) fn related_message_rows(
395 &self,
396 related_message_ids: &[String],
397 ) -> Result<Vec<Value>> {
398 let mut out = Vec::new();
399 for related_message_id in related_message_ids {
400 let related = self.read_message_by_id(related_message_id)?;
401 let time = related
402 .received_rfc3339
403 .as_deref()
404 .or(related.sent_rfc3339.as_deref())
405 .unwrap_or("");
406 out.push(json!({
407 "message": message_template_value(&related)?,
408 "view": {
409 "direction": markdown_table_cell(related.direction.as_deref().unwrap_or("")),
410 "from": markdown_table_cell(related.from.as_deref().unwrap_or("")),
411 "subject": markdown_table_cell(related.subject.as_deref().unwrap_or("")),
412 "time": markdown_table_cell(time),
413 "status": markdown_table_cell(&related.workspace.status),
414 },
415 }));
416 }
417 Ok(out)
418 }
419
420 pub(super) fn remove_stale_triage_views(&self, desired: &BTreeSet<String>) -> Result<usize> {
421 let triage_dir = self.root.join("triage");
422 if !triage_dir.exists() {
423 return Ok(0);
424 }
425 let mut removed = 0usize;
426 for entry in read_dir(&triage_dir, "read triage directory")? {
427 let path = entry.path();
428 if path.extension().and_then(|s| s.to_str()) != Some("md") {
429 continue;
430 }
431 let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
432 continue;
433 };
434 if !desired.contains(stem) {
435 remove_file(&path)?;
436 removed += 1;
437 }
438 }
439 Ok(removed)
440 }
441}