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