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