1use super::*;
2
3pub(super) fn archive_index_field_value(
4 field: ArchiveMessageIndexField,
5 message: &MessageFile,
6 item: &ArchiveMessageItem,
7 offset: &FixedOffset,
8) -> Option<Value> {
9 let value = match field {
10 ArchiveMessageIndexField::Time => {
11 message_time_datetime(message, offset).unwrap_or_default()
12 }
13 ArchiveMessageIndexField::From => message.from.clone().unwrap_or_default(),
14 ArchiveMessageIndexField::To => message.to.join(", "),
15 ArchiveMessageIndexField::Subject => message.subject.clone().unwrap_or_default(),
16 ArchiveMessageIndexField::Summary => item.summary.clone().unwrap_or_default(),
17 ArchiveMessageIndexField::MessageId => item.message_id.clone(),
18 ArchiveMessageIndexField::ArchiveTime => item.added_rfc3339.clone(),
19 ArchiveMessageIndexField::Link => String::new(),
20 };
21 let keep_empty = matches!(
22 field,
23 ArchiveMessageIndexField::Time
24 | ArchiveMessageIndexField::From
25 | ArchiveMessageIndexField::Link
26 | ArchiveMessageIndexField::MessageId
27 );
28 if value.is_empty() && !keep_empty {
29 None
30 } else {
31 Some(json!({
32 "kind": field.as_str(),
33 "value": value,
34 "href": format!("views/messages/{}.md", item.message_id),
35 }))
36 }
37}
38
39impl Workspace {
40 pub fn create_archive_message_category(
41 &self,
42 name: &str,
43 message_id: Option<&str>,
44 summary: Option<&str>,
45 reason: Option<&str>,
46 ) -> Result<Value> {
47 self.require_workspace()?;
48 validate_name("archive_name", name)?;
49 if let Some(message_id) = message_id {
50 validate_id("message_id", message_id)?;
51 if summary
52 .map(str::trim)
53 .filter(|value| !value.is_empty())
54 .is_none()
55 {
56 return Err(AppError::new(
57 "invalid_request",
58 "--summary is required when --message is supplied",
59 ));
60 }
61 }
62 let date = if let Some(message_id) = message_id {
63 self.message_date(message_id)?
64 } else {
65 workspace_local_date(&self.workspace_date_offset()?)
66 };
67 let archive_uid = self.next_archive_uid(&date)?;
68 let archive_dir = self.archive_message_dir_for_name(&archive_uid, name);
69 if archive_dir.exists() {
70 return Err(AppError::new(
71 "archive_exists",
72 format!(
73 "archive message category already exists: {}",
74 path_to_string(&archive_dir)
75 ),
76 ));
77 }
78 create_dir_all(&archive_dir.join("data"))?;
79 create_dir_all(&archive_dir.join("views/messages"))?;
80 write_string_new(
81 &archive_dir.join("notes.md"),
82 &new_notes_md(&self.root, self.template_language()?)?,
83 )?;
84 self.write_archive_messages_named(
85 &archive_uid,
86 name,
87 &ArchiveMessages::new_notification(&archive_uid, name, &now_rfc3339()),
88 )?;
89
90 let mut result = json!({
91 "code": "archive_message_created",
92 "archive_uid": archive_uid,
93 "archive_name": name,
94 "message_count": 0,
95 "path": rel_path(&self.root, &archive_dir),
96 });
97 if let Some(message_id) = message_id {
98 self.ensure_no_related_conversation(message_id)?;
99 let archived_rfc3339 = self.set_direct_message_archive(message_id, &archive_uid)?;
100 self.upsert_archive_message_item(&archive_uid, message_id, summary, &archived_rfc3339)?;
101 self.refresh_archive_message_category(&archive_uid)?;
102 let queue =
103 self.queue_archive_for_archived_messages(&[message_id.to_string()], None)?;
104 result = json!({
105 "code": "archive_message_created",
106 "archive_uid": archive_uid,
107 "archive_name": name,
108 "message_id": message_id,
109 "summary": summary,
110 "message_count": 1,
111 "path": rel_path(&self.root, &archive_dir),
112 "eligible_message_ids": queue.eligible_message_ids,
113 "location_count": queue.location_count,
114 "queued_location_count": queue.queued_location_count,
115 "queued": !queue.items.is_empty(),
116 "push_ids": queue.items.iter().map(|item| item.push_id.clone()).collect::<Vec<_>>(),
117 "push_id": queue.items.first().map(|item| item.push_id.clone())
118 });
119 } else {
120 self.refresh_archive_message_category(&archive_uid)?;
121 }
122 self.append_audit_event(
123 "archive_message_created",
124 vec![audit_target("archive", &archive_uid)],
125 reason.map(str::trim).filter(|value| !value.is_empty()),
126 json!({
127 "archive_uid": archive_uid,
128 "archive_name": name,
129 "message_id": message_id,
130 "summary": summary,
131 "path": rel_path(&self.root, &archive_dir),
132 }),
133 )?;
134 Ok(result)
135 }
136
137 pub fn archive_list(&self) -> Result<Value> {
138 self.require_workspace()?;
139 let cases = self.archive_case_items()?;
140 let messages = self.archive_message_category_items()?;
141 Ok(json!({
142 "code": "archive_list",
143 "case_count": cases.len(),
144 "message_count": messages.len(),
145 "case_path_templates": {
146 "case_path": "archive/cases/{case_dir}",
147 "view_path": "archive/cases/{case_dir}/case.md",
148 "data_path": "archive/cases/{case_dir}/data/case.json",
149 },
150 "message_path_templates": {
151 "archive_path": "archive/notifications/{archive_dir}",
152 "view_path": "archive/notifications/{archive_dir}/archive.md",
153 "data_path": "archive/notifications/{archive_dir}/data/notification.json",
154 },
155 "cases": cases,
156 "messages": messages,
157 }))
158 }
159
160 pub fn archive_list_cases(&self) -> Result<Value> {
161 self.require_workspace()?;
162 let cases = self.archive_case_items()?;
163 Ok(json!({
164 "code": "archive_case_list",
165 "count": cases.len(),
166 "path_templates": {
167 "case_path": "archive/cases/{case_dir}",
168 "view_path": "archive/cases/{case_dir}/case.md",
169 "data_path": "archive/cases/{case_dir}/data/case.json",
170 },
171 "items": cases,
172 }))
173 }
174
175 pub fn archive_list_messages(&self) -> Result<Value> {
176 self.require_workspace()?;
177 let messages = self.archive_message_category_items()?;
178 Ok(json!({
179 "code": "archive_message_list",
180 "count": messages.len(),
181 "path_templates": {
182 "archive_path": "archive/notifications/{archive_dir}",
183 "view_path": "archive/notifications/{archive_dir}/archive.md",
184 "data_path": "archive/notifications/{archive_dir}/data/notification.json",
185 },
186 "items": messages,
187 }))
188 }
189
190 pub fn archive_message_show(&self, archive_ref: &str) -> Result<Value> {
191 self.require_workspace()?;
192 let (archive_uid, archive_dir) = self.resolve_archive_message_category(archive_ref)?;
193 self.refresh_archive_message_category(&archive_uid)?;
194 let data = self.read_archive_messages(&archive_uid)?;
195 let archive_path = self.archive_message_index_path(&archive_uid);
196 let text = read_to_string(&archive_path, "read archive message view")?;
197 Ok(json!({
198 "code": "archive_message",
199 "archive_uid": archive_uid,
200 "archive_name": data.collection_name,
201 "path": rel_path(&self.root, &archive_dir),
202 "view_path": rel_path(&self.root, &archive_path),
203 "notes_path": rel_path(&self.root, &archive_dir.join("notes.md")),
204 "message_count": data.items.len(),
205 "items": data.items,
206 "text": text,
207 }))
208 }
209
210 pub fn archive_message_restore(
211 &self,
212 archive_ref: &str,
213 message_id: &str,
214 reason: Option<&str>,
215 ) -> Result<Value> {
216 self.require_workspace()?;
217 let reason = self.checked_reason(reason)?;
218 let (archive_uid, _) = self.resolve_archive_message_category(archive_ref)?;
219 self.restore_direct_archive_message(
220 &archive_uid,
221 message_id,
222 reason,
223 "message_restored",
224 "message_restored",
225 )
226 }
227
228 pub(super) fn restore_direct_archive_message(
229 &self,
230 archive_uid: &str,
231 message_id: &str,
232 reason: Option<&str>,
233 event_kind: &str,
234 result_code: &str,
235 ) -> Result<Value> {
236 validate_archive_uid(archive_uid)?;
237 validate_id("message_id", message_id)?;
238 let mut message = self.read_message_by_id(message_id)?;
239 if message.workspace.archive_uid.as_deref() != Some(archive_uid) {
240 return Err(AppError::new(
241 "archive_entry_not_found",
242 format!("message {message_id} is not in archive {archive_uid}"),
243 ));
244 }
245 let removed_push = crate::push_queue::remove_pending_message_pushes(
246 &self.root,
247 message_id,
248 "message.archive",
249 )?;
250 let push_ids = removed_push
251 .iter()
252 .map(|item| item.push_id.clone())
253 .collect::<Vec<_>>();
254 self.remove_archive_message_item(archive_uid, message_id)?;
255 let view_path = self.archive_message_view_path(archive_uid, message_id);
256 if view_path.exists() {
257 remove_file(&view_path)?;
258 }
259 message.workspace.status = "triage".to_string();
260 message.workspace.archive_uid = None;
261 message.workspace.archived_rfc3339 = None;
262 message.workspace.origin = None;
263 message.workspace.remote_sync = None;
264 self.write_message_cache(&message)?;
265 self.refresh_message_after_ref_change(message_id)?;
266 self.clear_message_pending_pushes(message_id, &push_ids, false)?;
267 self.refresh_archive_message_category(archive_uid)?;
268 self.append_audit_event(
269 event_kind,
270 vec![
271 audit_target("message", message_id),
272 audit_target("archive", archive_uid),
273 ],
274 reason,
275 json!({
276 "message_id": message_id,
277 "archive_uid": archive_uid,
278 "from_path": format!("archive/notifications/{archive_uid}/views/messages/{message_id}.md"),
279 "to_path": format!("triage/{message_id}.md"),
280 "removed_push_ids": push_ids.clone(),
281 }),
282 )?;
283 Ok(json!({
284 "code": result_code,
285 "message_id": message_id,
286 "archive_uid": archive_uid,
287 "triage_path": format!("triage/{message_id}.md"),
288 "removed_push_count": push_ids.len(),
289 "push_ids": push_ids,
290 }))
291 }
292
293 pub fn archive_message_move(
294 &self,
295 archive_ref: &str,
296 message_id: &str,
297 new_archive_ref: &str,
298 reason: Option<&str>,
299 ) -> Result<Value> {
300 self.require_workspace()?;
301 let reason = self.checked_reason(reason)?;
302 validate_id("message_id", message_id)?;
303 let (archive_uid, _) = self.resolve_archive_message_category(archive_ref)?;
304 let (new_archive_uid, _) = self.resolve_archive_message_category(new_archive_ref)?;
305 if archive_uid == new_archive_uid {
306 return Ok(json!({
307 "code": "message_archive_moved",
308 "message_id": message_id,
309 "archive_uid": archive_uid,
310 "new_archive_uid": new_archive_uid,
311 "changed": false,
312 }));
313 }
314 let mut old_data = self.read_archive_messages(&archive_uid)?;
315 let pos = old_data
316 .items
317 .iter()
318 .position(|item| item.message_id == message_id)
319 .ok_or_else(|| {
320 AppError::new(
321 "archive_entry_not_found",
322 format!("message {message_id} is not in archive {archive_uid}"),
323 )
324 })?;
325 let item = old_data.items.remove(pos);
326 old_data.updated_rfc3339 = Some(now_rfc3339());
327 self.write_archive_messages(&archive_uid, &old_data)?;
328 let old_view = self.archive_message_view_path(&archive_uid, message_id);
329 if old_view.exists() {
330 remove_file(&old_view)?;
331 }
332 let mut message = self.read_message_by_id(message_id)?;
333 message.workspace.status = "archived".to_string();
334 message.workspace.archive_uid = Some(new_archive_uid.to_string());
335 message.workspace.archived_rfc3339 = Some(item.added_rfc3339.clone());
336 message.workspace.remote_sync = None;
337 self.write_message_cache(&message)?;
338 self.upsert_archive_message_item(
339 &new_archive_uid,
340 message_id,
341 item.summary.as_deref(),
342 &item.added_rfc3339,
343 )?;
344 self.refresh_archive_message_category(&archive_uid)?;
345 self.refresh_archive_message_category(&new_archive_uid)?;
346 self.append_audit_event(
347 "message_archive_moved",
348 vec![
349 audit_target("message", message_id),
350 audit_target("archive", &archive_uid),
351 audit_target("archive", &new_archive_uid),
352 ],
353 reason,
354 json!({
355 "message_id": message_id,
356 "from_archive_uid": archive_uid,
357 "archive_uid": new_archive_uid,
358 }),
359 )?;
360 Ok(json!({
361 "code": "message_archive_moved",
362 "message_id": message_id,
363 "from_archive_uid": archive_uid,
364 "archive_uid": new_archive_uid,
365 "changed": true,
366 }))
367 }
368
369 pub fn archive_message_rename(
370 &self,
371 archive_ref: &str,
372 name: &str,
373 reason: Option<&str>,
374 ) -> Result<Value> {
375 self.require_workspace()?;
376 let reason = self.checked_reason(reason)?;
377 validate_name("archive_name", name)?;
378 let (archive_uid, from) = self.resolve_archive_message_category(archive_ref)?;
379 let mut data = self.read_archive_messages(&archive_uid)?;
380 let old_name = data.collection_name.clone();
381 let to = self.archive_message_dir_for_name(&archive_uid, name);
382 let changed_path = to != from;
383 if changed_path && to.exists() {
384 return Err(AppError::new(
385 "archive_exists",
386 format!(
387 "archive message category already exists: {}",
388 path_to_string(&to)
389 ),
390 ));
391 }
392 if let Some(parent) = to.parent() {
393 create_dir_all(parent)?;
394 }
395 if changed_path {
396 fs::rename(&from, &to)
397 .map_err(|e| AppError::io("rename archive message category", &e))?;
398 }
399 data.collection_name = name.to_string();
400 data.updated_rfc3339 = Some(now_rfc3339());
401 self.write_archive_messages(&archive_uid, &data)?;
402 self.refresh_archive_message_category(&archive_uid)?;
403 self.append_audit_event(
404 "message_archive_category_renamed",
405 vec![audit_target("archive", &archive_uid)],
406 reason,
407 json!({
408 "archive_uid": archive_uid,
409 "old_archive_name": old_name,
410 "archive_name": name,
411 "message_count": data.items.len(),
412 }),
413 )?;
414 Ok(json!({
415 "code": "message_archive_category_renamed",
416 "archive_uid": archive_uid,
417 "old_archive_name": old_name,
418 "archive_name": name,
419 "path": rel_path(&self.root, &to),
420 "message_count": data.items.len(),
421 "changed": old_name != name || changed_path,
422 }))
423 }
424
425 pub fn archive_message_set_summary(
426 &self,
427 archive_ref: &str,
428 message_id: &str,
429 summary: &str,
430 reason: Option<&str>,
431 ) -> Result<Value> {
432 self.require_workspace()?;
433 let reason = self.checked_reason(reason)?;
434 validate_id("message_id", message_id)?;
435 let (archive_uid, _) = self.resolve_archive_message_category(archive_ref)?;
436 self.update_archive_message_summary(&archive_uid, message_id, summary)?;
437 self.refresh_archive_message_category(&archive_uid)?;
438 self.append_audit_event(
439 "message_archive_summary_set",
440 vec![
441 audit_target("message", message_id),
442 audit_target("archive", &archive_uid),
443 ],
444 reason,
445 json!({
446 "message_id": message_id,
447 "archive_uid": archive_uid,
448 "summary": summary,
449 }),
450 )?;
451 Ok(json!({
452 "code": "message_archive_summary_set",
453 "message_id": message_id,
454 "archive_uid": archive_uid,
455 "summary": summary,
456 }))
457 }
458
459 pub fn archive_message_notes_show(&self, archive_ref: &str) -> Result<Value> {
460 let (archive_uid, _) = self.resolve_archive_message_category(archive_ref)?;
461 let path = self.archive_message_notes_path(&archive_uid);
462 self.notes_show(
463 "archive_message_notes",
464 vec![audit_target("archive", &archive_uid)],
465 &path,
466 )
467 }
468
469 pub fn archive_message_notes_append(&self, archive_ref: &str, text: &str) -> Result<Value> {
470 let (archive_uid, _) = self.resolve_archive_message_category(archive_ref)?;
471 let path = self.archive_message_notes_path(&archive_uid);
472 self.notes_append(
473 "archive_message_notes_appended",
474 vec![audit_target("archive", &archive_uid)],
475 &path,
476 text,
477 )
478 }
479
480 pub fn archive_message_notes_replace(&self, archive_ref: &str, text: &str) -> Result<Value> {
481 let (archive_uid, _) = self.resolve_archive_message_category(archive_ref)?;
482 let path = self.archive_message_notes_path(&archive_uid);
483 self.notes_replace(
484 "archive_message_notes_replaced",
485 vec![audit_target("archive", &archive_uid)],
486 &path,
487 text,
488 )
489 }
490
491 pub(super) fn set_direct_message_archive(
492 &self,
493 message_id: &str,
494 archive_uid: &str,
495 ) -> Result<String> {
496 let now = now_rfc3339();
497 let mut msg = self.read_message_by_id(message_id)?;
498 if let Some(existing) = msg.workspace.archive_uid.as_deref() {
499 if existing != archive_uid {
500 return Err(AppError::new(
501 "message_already_archived",
502 format!(
503 "message {message_id} is already archived in {existing}; use archive message {existing} move"
504 ),
505 ));
506 }
507 }
508 msg.workspace.status = "archived".to_string();
509 msg.workspace.archive_uid = Some(archive_uid.to_string());
510 let archived_rfc3339 = msg
511 .workspace
512 .archived_rfc3339
513 .clone()
514 .unwrap_or_else(|| now.clone());
515 msg.workspace.archived_rfc3339 = Some(archived_rfc3339.clone());
516 msg.workspace.origin = None;
517 msg.workspace.remote_sync = None;
518 self.clear_message_from_all_dispositions(message_id)?;
519 self.write_message_materialized_cache(&msg)?;
520 self.remove_triage_view_for_message(message_id)?;
521 Ok(archived_rfc3339)
522 }
523
524 pub(super) fn archive_case_path_for_name(&self, case_uid: &str, name: &str) -> PathBuf {
525 self.root
526 .join("archive")
527 .join("cases")
528 .join(case_dir_name(case_uid, name))
529 }
530
531 pub(super) fn archive_message_dir(&self, archive_uid: &str) -> PathBuf {
532 self.find_archive_message_dir_by_uid(archive_uid)
533 .ok()
534 .flatten()
535 .unwrap_or_else(|| {
536 self.root
537 .join("archive")
538 .join("notifications")
539 .join(archive_uid)
540 })
541 }
542
543 pub(super) fn archive_message_dir_for_name(&self, archive_uid: &str, name: &str) -> PathBuf {
544 self.root
545 .join("archive")
546 .join("notifications")
547 .join(archive_dir_name(archive_uid, name))
548 }
549
550 pub(super) fn archive_message_index_path(&self, archive_uid: &str) -> PathBuf {
551 self.archive_message_dir(archive_uid).join("archive.md")
552 }
553
554 pub(super) fn archive_message_notes_path(&self, archive_uid: &str) -> PathBuf {
555 self.archive_message_dir(archive_uid).join("notes.md")
556 }
557
558 pub(super) fn archive_message_json_path(&self, archive_uid: &str) -> PathBuf {
559 self.archive_message_dir(archive_uid)
560 .join("data")
561 .join("notification.json")
562 }
563
564 pub(super) fn archive_message_view_path(&self, archive_uid: &str, message_id: &str) -> PathBuf {
565 self.archive_message_dir(archive_uid)
566 .join("views")
567 .join("messages")
568 .join(format!("{message_id}.md"))
569 }
570
571 pub(super) fn read_archive_messages(&self, archive_uid: &str) -> Result<ArchiveMessages> {
572 validate_archive_uid(archive_uid)?;
573 let path = self.archive_message_json_path(archive_uid);
574 if !path.exists() {
575 return Ok(ArchiveMessages::new_notification(
576 archive_uid,
577 "",
578 &now_rfc3339(),
579 ));
580 }
581 let data = read_to_string(&path, "read archive messages")?;
582 let messages: ArchiveMessages = serde_json::from_str(&data)
583 .map_err(|e| AppError::json("parse archive messages", &e))?;
584 if messages.schema_name != ARCHIVE_NOTIFICATION_SCHEMA_NAME
585 || messages.schema_version != MESSAGE_COLLECTION_SCHEMA_VERSION
586 || messages.collection_uid != archive_uid
587 {
588 return Err(AppError::new(
589 "archive_messages_invalid",
590 format!(
591 "invalid archive messages schema: {}",
592 rel_path(&self.root, &path)
593 ),
594 ));
595 }
596 Ok(messages)
597 }
598
599 pub(super) fn write_archive_messages(
600 &self,
601 archive_uid: &str,
602 data: &ArchiveMessages,
603 ) -> Result<()> {
604 self.write_archive_messages_named(archive_uid, &data.collection_name, data)
605 }
606
607 pub(super) fn write_archive_messages_named(
608 &self,
609 archive_uid: &str,
610 archive_name: &str,
611 data: &ArchiveMessages,
612 ) -> Result<()> {
613 let mut normalized = data.clone();
614 normalized.normalize(ARCHIVE_NOTIFICATION_SCHEMA_NAME, archive_uid, archive_name);
615 write_json_pretty(&self.archive_message_json_path(archive_uid), &normalized)
616 }
617
618 pub(super) fn upsert_archive_message_item(
619 &self,
620 archive_uid: &str,
621 message_id: &str,
622 summary: Option<&str>,
623 added_rfc3339: &str,
624 ) -> Result<()> {
625 let mut data = self.read_archive_messages(archive_uid)?;
626 let summary = summary
627 .map(str::trim)
628 .filter(|value| !value.is_empty())
629 .map(ToString::to_string);
630 if let Some(item) = data
631 .items
632 .iter_mut()
633 .find(|item| item.message_id == message_id)
634 {
635 if summary.is_some() {
636 item.summary = summary;
637 }
638 item.added_rfc3339 = added_rfc3339.to_string();
639 } else {
640 data.items.push(ArchiveMessageItem {
641 message_id: message_id.to_string(),
642 summary,
643 added_rfc3339: added_rfc3339.to_string(),
644 });
645 }
646 data.updated_rfc3339 = Some(now_rfc3339());
647 self.write_archive_messages(archive_uid, &data)
648 }
649
650 pub(super) fn remove_archive_message_item(
651 &self,
652 archive_uid: &str,
653 message_id: &str,
654 ) -> Result<()> {
655 let mut data = self.read_archive_messages(archive_uid)?;
656 let before = data.items.len();
657 data.items.retain(|item| item.message_id != message_id);
658 if data.items.len() == before {
659 return Err(AppError::new(
660 "archive_entry_not_found",
661 format!("message {message_id} is not in archive {archive_uid}"),
662 ));
663 }
664 data.updated_rfc3339 = Some(now_rfc3339());
665 self.write_archive_messages(archive_uid, &data)
666 }
667
668 pub(super) fn update_archive_message_summary(
669 &self,
670 archive_uid: &str,
671 message_id: &str,
672 summary: &str,
673 ) -> Result<()> {
674 let mut data = self.read_archive_messages(archive_uid)?;
675 let item = data
676 .items
677 .iter_mut()
678 .find(|item| item.message_id == message_id)
679 .ok_or_else(|| {
680 AppError::new(
681 "archive_entry_not_found",
682 format!("message {message_id} is not in archive {archive_uid}"),
683 )
684 })?;
685 item.summary = Some(summary.trim().to_string()).filter(|value| !value.is_empty());
686 data.updated_rfc3339 = Some(now_rfc3339());
687 self.write_archive_messages(archive_uid, &data)
688 }
689
690 pub(super) fn refresh_archive_indexes(&self) -> Result<()> {
691 create_dir_all(&self.root.join("archive/cases"))?;
692 create_dir_all(&self.root.join("archive/notifications"))?;
693 let language = self.template_language()?;
694 let mut renderer = MarkdownTemplateRenderer::new(&self.root, language);
695 for archive_uid in self.archive_message_category_ids()? {
696 self.refresh_archive_message_category_with_renderer(&archive_uid, &mut renderer, true)?;
697 }
698 Ok(())
699 }
700
701 pub(super) fn refresh_archive_message_category(&self, archive_uid: &str) -> Result<()> {
702 let language = self.template_language()?;
703 let mut renderer = MarkdownTemplateRenderer::new(&self.root, language);
704 self.refresh_archive_message_category_with_renderer(archive_uid, &mut renderer, true)?;
705 Ok(())
706 }
707
708 pub(super) fn refresh_archive_message_category_with_renderer(
709 &self,
710 archive_uid: &str,
711 renderer: &mut MarkdownTemplateRenderer<'_>,
712 sync_state: bool,
713 ) -> Result<ArchiveMessageViewRefresh> {
714 validate_archive_uid(archive_uid)?;
715 let archive_dir = self.archive_message_dir(archive_uid);
716 let messages_dir = archive_dir.join("views").join("messages");
717 create_dir_all(&messages_dir)?;
718 let mut data = self.read_archive_messages(archive_uid)?;
719 if sync_state {
720 let before = data.items.len();
721 data.items.retain(|item| {
722 self.read_message_by_id(&item.message_id)
723 .map(|message| message.workspace.archive_uid.as_deref() == Some(archive_uid))
724 .unwrap_or(false)
725 });
726 if data.items.len() != before {
727 data.updated_rfc3339 = Some(now_rfc3339());
728 self.write_archive_messages(archive_uid, &data)?;
729 }
730 }
731 let items = data
732 .items
733 .iter()
734 .filter(|item| {
735 self.read_message_by_id(&item.message_id)
736 .map(|message| message.workspace.archive_uid.as_deref() == Some(archive_uid))
737 .unwrap_or(false)
738 })
739 .cloned()
740 .collect::<Vec<_>>();
741 let desired = items
742 .iter()
743 .map(|item| item.message_id.clone())
744 .collect::<BTreeSet<_>>();
745 let mut message_count = 0usize;
746 for item in &items {
747 let message = self.read_message_by_id(&item.message_id)?;
748 let view_path = self.archive_message_view_path(archive_uid, &item.message_id);
749 write_string(
750 &view_path,
751 &self.render_archive_message_view(
752 &message,
753 archive_uid,
754 &data.collection_name,
755 item,
756 renderer,
757 view_path.parent(),
758 )?,
759 )?;
760 message_count += 1;
761 }
762 if messages_dir.exists() {
763 for entry in read_dir(&messages_dir, "read archive message views")? {
764 let path = entry.path();
765 if path.extension().and_then(|s| s.to_str()) != Some("md") {
766 continue;
767 }
768 let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
769 continue;
770 };
771 if !desired.contains(stem) {
772 remove_file(&path)?;
773 }
774 }
775 }
776 let config = MailConfig::load(&self.root)?;
777 write_string(
778 &archive_dir.join("archive.md"),
779 &self.render_archive_message_index(
780 archive_uid,
781 &data.collection_name,
782 &items,
783 &config,
784 renderer,
785 )?,
786 )?;
787 Ok(ArchiveMessageViewRefresh {
788 archive_message_index_count: 1,
789 archive_message_count: message_count,
790 })
791 }
792
793 pub(super) fn render_archive_message_view(
794 &self,
795 message: &MessageFile,
796 archive_uid: &str,
797 archive_name: &str,
798 item: &ArchiveMessageItem,
799 renderer: &mut MarkdownTemplateRenderer<'_>,
800 output_dir: Option<&Path>,
801 ) -> Result<String> {
802 let config = MailConfig::load(&self.root)?;
803 let title = message.subject.as_deref().unwrap_or("");
804 let message_value = message_template_value(message)?;
805 let item_value = serde_json::to_value(item)
806 .map_err(|e| AppError::json("serialize archive message item", &e))?;
807 let generated_rfc3339 = now_rfc3339();
808 let conversation =
809 self.message_conversation_with_renderer(message, &config, renderer, output_dir)?;
810 let context = json!({
811 "frontmatter": {
812 "kind": "archive_message",
813 "message_id": message.message_id.as_str(),
814 "archive_uid": archive_uid,
815 "archive_name": archive_name,
816 "added_rfc3339": item.added_rfc3339.as_str(),
817 "generated_rfc3339": generated_rfc3339.as_str(),
818 },
819 "archive": {
820 "archive_uid": archive_uid,
821 "archive_name": archive_name,
822 },
823 "message": message_value,
824 "item": item_value,
825 "view": {
826 "language": config.resolved_language_bcp47(),
827 "title": title,
828 "summary": item.summary.as_deref().unwrap_or(""),
829 "added_rfc3339": item.added_rfc3339.as_str(),
830 "generated_rfc3339": generated_rfc3339.as_str(),
831 "conversation": conversation.trim(),
832 },
833 });
834 renderer.render(TemplateKey::ArchiveMessage, &context)
835 }
836
837 pub(super) fn render_archive_message_index(
838 &self,
839 archive_uid: &str,
840 archive_name: &str,
841 data_items: &[ArchiveMessageItem],
842 config: &MailConfig,
843 renderer: &mut MarkdownTemplateRenderer<'_>,
844 ) -> Result<String> {
845 let mut items = data_items.to_vec();
846 items.sort_by(|a, b| {
847 let a_time = self
848 .read_message_by_id(&a.message_id)
849 .ok()
850 .and_then(|message| message_time(&message))
851 .unwrap_or_else(|| a.added_rfc3339.clone());
852 let b_time = self
853 .read_message_by_id(&b.message_id)
854 .ok()
855 .and_then(|message| message_time(&message))
856 .unwrap_or_else(|| b.added_rfc3339.clone());
857 compare_rfc3339_asc(&b_time, &a_time).then_with(|| a.message_id.cmp(&b.message_id))
858 });
859 let mut rendered_items = Vec::new();
860 let offset = config.resolved_timezone_offset();
861 for item in &items {
862 let message = self.read_message_by_id(&item.message_id)?;
863 let fields = config
864 .archive
865 .message_index
866 .item_fields
867 .iter()
868 .filter_map(|field| archive_index_field_value(*field, &message, item, &offset))
869 .collect::<Vec<_>>();
870 let has_message_id = config
871 .archive
872 .message_index
873 .item_fields
874 .contains(&ArchiveMessageIndexField::MessageId);
875 let has_link = config
876 .archive
877 .message_index
878 .item_fields
879 .contains(&ArchiveMessageIndexField::Link);
880 let mut second = Vec::new();
881 if !has_message_id || !has_link {
882 if !has_message_id {
883 second.push(json!({
884 "kind": "message_id",
885 "value": item.message_id.as_str(),
886 }));
887 }
888 if !has_link {
889 second.push(json!({
890 "kind": "link",
891 "href": format!("views/messages/{}.md", item.message_id),
892 }));
893 }
894 }
895 let title = item
896 .summary
897 .as_deref()
898 .filter(|value| !value.trim().is_empty())
899 .or(message.subject.as_deref())
900 .unwrap_or(item.message_id.as_str())
901 .to_string();
902 let mut rendered_item = thread_item_common(
903 &message,
904 &offset,
905 config.template_language(),
906 format!("views/messages/{}.md", item.message_id),
907 title,
908 )?;
909 if let Value::Object(map) = &mut rendered_item {
910 if let Some(Value::Object(view)) = map.get_mut("view") {
911 view.insert(
912 "summary".to_string(),
913 json!(item.summary.as_deref().unwrap_or("")),
914 );
915 view.insert(
916 "display_summary".to_string(),
917 json!(item
918 .summary
919 .as_deref()
920 .map(markdown_inline)
921 .unwrap_or_default()),
922 );
923 view.insert(
924 "added_rfc3339".to_string(),
925 json!(item.added_rfc3339.as_str()),
926 );
927 view.insert(
928 "added_time".to_string(),
929 time_context(&item.added_rfc3339, &offset),
930 );
931 view.insert("fields".to_string(), json!(fields));
932 view.insert("secondary".to_string(), json!(second));
933 }
934 map.insert(
935 "item".to_string(),
936 serde_json::to_value(item)
937 .map_err(|e| AppError::json("serialize archive message item", &e))?,
938 );
939 }
940 rendered_items.push(rendered_item);
941 }
942 let generated_rfc3339 = now_rfc3339();
943 let context = json!({
944 "archive": {
945 "archive_uid": archive_uid,
946 "archive_name": archive_name,
947 },
948 "items": rendered_items,
949 "view": {
950 "language": config.resolved_language_bcp47(),
951 "message_count": rendered_items.len(),
952 "generated_rfc3339": generated_rfc3339,
953 },
954 "config": {
955 "archive": {
956 "message_index": {
957 "item_fields": config.archive.message_index.item_fields
958 .iter()
959 .map(|field| field.as_str())
960 .collect::<Vec<_>>(),
961 },
962 },
963 },
964 });
965 renderer.render(TemplateKey::ArchiveMessageIndex, &context)
966 }
967
968 pub(super) fn archive_message_category_ids(&self) -> Result<Vec<String>> {
969 let dir = self.root.join("archive/notifications");
970 if !dir.exists() {
971 return Ok(Vec::new());
972 }
973 let mut ids = Vec::new();
974 for entry in read_dir(&dir, "read archive messages directory")? {
975 let path = entry.path();
976 if path.is_dir() {
977 if let Some(name) = path.file_name().and_then(|s| s.to_str()) {
978 if path.join("data").join("notification.json").is_file() {
979 if let Some(uid) = archive_uid_from_dir_name(name) {
980 ids.push(uid);
981 }
982 }
983 }
984 }
985 }
986 ids.sort();
987 ids.dedup();
988 Ok(ids)
989 }
990
991 pub(super) fn archive_message_category_items(&self) -> Result<Vec<Value>> {
992 let mut out = Vec::new();
993 for archive_uid in self.archive_message_category_ids()? {
994 let data = self.read_archive_messages(&archive_uid)?;
995 let path = self.archive_message_dir(&archive_uid);
996 out.push(json!({
997 "archive_uid": archive_uid,
998 "archive_name": data.collection_name,
999 "archive_dir": path
1000 .file_name()
1001 .and_then(|s| s.to_str())
1002 .unwrap_or_default(),
1003 }));
1004 }
1005 Ok(out)
1006 }
1007
1008 pub(super) fn resolve_archive_message_category(
1009 &self,
1010 archive_ref: &str,
1011 ) -> Result<(String, PathBuf)> {
1012 let archive_uid = parse_archive_ref(archive_ref)?;
1013 self.find_archive_message_dir_by_uid(&archive_uid)?
1014 .map(|path| (archive_uid.clone(), path))
1015 .ok_or_else(|| {
1016 AppError::new(
1017 "archive_not_found",
1018 format!("archive message category not found: {archive_uid}"),
1019 )
1020 })
1021 }
1022
1023 pub(super) fn find_archive_message_dir_by_uid(
1024 &self,
1025 archive_uid: &str,
1026 ) -> Result<Option<PathBuf>> {
1027 validate_archive_uid(archive_uid)?;
1028 let dir = self.root.join("archive/notifications");
1029 if !dir.exists() {
1030 return Ok(None);
1031 }
1032 let mut matches = Vec::new();
1033 for entry in read_dir(&dir, "read archive messages directory")? {
1034 let path = entry.path();
1035 if !path.is_dir() {
1036 continue;
1037 }
1038 let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
1039 continue;
1040 };
1041 if archive_uid_from_dir_name(name).as_deref() == Some(archive_uid) {
1042 matches.push(path);
1043 }
1044 }
1045 match matches.len() {
1046 0 => Ok(None),
1047 1 => Ok(matches.into_iter().next()),
1048 _ => Err(AppError::new(
1049 "duplicate_archive_uid",
1050 format!("duplicate archive uid found: {archive_uid}"),
1051 )),
1052 }
1053 }
1054}