1use std::{cmp::Ordering, path::{Path, PathBuf}, str::FromStr};
8
9use indexmap::IndexMap;
10
11use caretta_id::CarettaId;
12use chrono::{Datelike as _, NaiveDateTime};
13use rusqlite::Connection;
14
15use crate::{
16 cache,
17 entry::{Entry, EntryHeader, EventMeta, Frontmatter, TaskMeta},
18 entry_ref::EntryRef,
19 error::{Error, Result},
20 journal::{DuplicateTitlePolicy, Journal, slugify},
21 journal_state::JournalState,
22 parser::{read_entry, render_entry},
23 period::Period,
24};
25
26#[derive(Debug, Default)]
41pub enum UpdateOption<T> {
42 Set(T),
43 Clear,
44 #[default]
45 Unchanged,
46}
47
48impl<'de, T: serde::Deserialize<'de>> serde::Deserialize<'de> for UpdateOption<T> {
49 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
50 Ok(match Option::<T>::deserialize(deserializer)? {
51 None => UpdateOption::Clear,
52 Some(v) => UpdateOption::Set(v),
53 })
54 }
55}
56
57impl<T: schemars::JsonSchema> schemars::JsonSchema for UpdateOption<T> {
58 fn schema_name() -> std::borrow::Cow<'static, str> {
59 format!("Nullable_{}", T::schema_name()).into()
60 }
61
62 fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
63 Option::<T>::json_schema(generator)
65 }
66
67 fn inline_schema() -> bool {
68 true
69 }
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
81pub enum SortField {
82 #[default]
84 Unsorted,
85 Id,
86 Title,
87 TaskStatus,
88 CreatedAt,
89 UpdatedAt,
90 TaskDue,
91 EventStart,
92}
93
94impl FromStr for SortField {
95 type Err = String;
96 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
97 match s {
98 "id" => Ok(Self::Id),
99 "title" => Ok(Self::Title),
100 "task_status" => Ok(Self::TaskStatus),
101 "created_at" => Ok(Self::CreatedAt),
102 "updated_at" => Ok(Self::UpdatedAt),
103 "task_due" => Ok(Self::TaskDue),
104 "event_start" => Ok(Self::EventStart),
105 other => Err(format!(
106 "unknown sort field `{other}`; expected one of: \
107 id, title, task_status, created_at, updated_at, task_due, event_start"
108 )),
109 }
110 }
111}
112
113#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
115pub enum SortOrder {
116 #[default]
117 Asc,
118 Desc,
119}
120
121impl FromStr for SortOrder {
122 type Err = String;
123 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
124 match s {
125 "asc" => Ok(Self::Asc),
126 "desc" => Ok(Self::Desc),
127 other => Err(format!("unknown sort order `{other}`; expected `asc` or `desc`")),
128 }
129 }
130}
131
132#[derive(Debug, Default, Clone)]
149pub struct FieldSelector {
150 pub task_overdue: bool,
151 pub task_in_progress: bool,
152 pub task_unstarted: bool,
153 pub event_span: bool,
154 pub created_at: bool,
155 pub updated_at: bool,
156}
157
158impl FieldSelector {
159 pub fn is_empty(&self) -> bool {
164 !self.task_overdue && !self.task_in_progress && !self.task_unstarted
165 && !self.event_span && !self.created_at && !self.updated_at
166 }
167
168 pub fn active() -> Self {
174 Self {
175 task_overdue: true,
176 task_in_progress: true,
177 task_unstarted: false,
178 event_span: true,
179 created_at: true,
180 updated_at: true,
181 }
182 }
183}
184
185#[derive(Debug, Default)]
196pub struct EntryFilter {
197 pub period: Option<Period>,
199 pub fields: FieldSelector,
201 pub task_status: Vec<String>,
203 pub tags: Vec<String>,
205 pub sort_by: SortField,
207 pub sort_order: SortOrder,
209}
210
211impl EntryFilter {
212 pub fn has_timestamp_filter(&self) -> bool {
213 self.period.is_some() || !self.fields.is_empty()
214 }
215
216 pub fn has_any_filter(&self) -> bool {
217 self.has_timestamp_filter() || !self.task_status.is_empty() || !self.tags.is_empty()
218 }
219
220 pub fn matches(&self, entry: &EntryHeader) -> (bool, Vec<MatchFlag>) {
225 let mut labels = Vec::new();
226
227 let timestamp_ok = if self.has_timestamp_filter() {
228 let event_start_val = entry.frontmatter.event.as_ref().map(|e| e.start);
229 let event_end_val = entry.frontmatter.event.as_ref().map(|e| e.end);
230 let created_val = Some(entry.frontmatter.created_at);
231 let updated_val = Some(entry.frontmatter.updated_at);
232
233 if let Some(p) = &self.period {
234 let all = self.fields.is_empty();
237 if (all || self.fields.event_span) && p.overlaps_event(event_start_val, event_end_val) { labels.push(MatchFlag::EventSpan); }
238 if (all || self.fields.created_at) && p.matches(created_val) { labels.push(MatchFlag::CreatedAt); }
239 if (all || self.fields.updated_at) && p.matches(updated_val) { labels.push(MatchFlag::UpdatedAt); }
240
241 if self.fields.task_overdue {
243 if let Period::Range(_, end) = p {
244 let is_overdue = entry.frontmatter.task.as_ref().is_some_and(|t| {
245 t.closed_at.is_none() && t.due.is_some_and(|due| due <= *end)
246 });
247 if is_overdue { labels.push(MatchFlag::TaskOverdue); }
248 }
249 }
250
251 if self.fields.task_in_progress {
253 if let Period::Range(_, end) = p {
254 let is_in_progress = entry.frontmatter.task.as_ref().is_some_and(|t| {
255 t.closed_at.is_none() && t.started_at.is_some_and(|sa| sa <= *end)
256 });
257 if is_in_progress { labels.push(MatchFlag::TaskInProgress); }
258 }
259 }
260
261 if self.fields.task_unstarted {
263 let is_unstarted = entry.frontmatter.task.as_ref().is_some_and(|t| {
264 t.started_at.is_none() && t.closed_at.is_none()
265 });
266 if is_unstarted { labels.push(MatchFlag::TaskUnstarted); }
267 }
268 } else {
269 if self.fields.event_span && (event_start_val.is_some() || event_end_val.is_some()) { labels.push(MatchFlag::EventSpan); }
271 if self.fields.task_overdue {
275 let now = chrono::Local::now().naive_local();
276 let is_overdue = entry.frontmatter.task.as_ref().is_some_and(|t| {
277 t.closed_at.is_none() && t.due.is_some_and(|due| due < now)
278 });
279 if is_overdue { labels.push(MatchFlag::TaskOverdue); }
280 }
281
282 if self.fields.task_in_progress {
284 let is_in_progress = entry.frontmatter.task.as_ref().is_some_and(|t| {
285 t.closed_at.is_none() && t.started_at.is_some()
286 });
287 if is_in_progress { labels.push(MatchFlag::TaskInProgress); }
288 }
289
290 if self.fields.task_unstarted {
292 let is_unstarted = entry.frontmatter.task.as_ref().is_some_and(|t| {
293 t.started_at.is_none() && t.closed_at.is_none()
294 });
295 if is_unstarted { labels.push(MatchFlag::TaskUnstarted); }
296 }
297 }
298
299 labels.dedup();
300 !labels.is_empty()
301 } else {
302 true
303 };
304
305 let status_ok = if !self.task_status.is_empty() {
306 entry.frontmatter.task.as_ref().is_some_and(|t| {
307 let s = t.status.as_str();
308 self.task_status.iter().any(|ts| ts == s)
309 })
310 } else {
311 true
312 };
313
314 let tags_ok = if !self.tags.is_empty() {
315 self.tags.iter().all(|tag| entry.frontmatter.tags.contains(tag))
316 } else {
317 true
318 };
319
320 (timestamp_ok && status_ok && tags_ok, labels)
321 }
322}
323
324#[derive(Debug, Clone, Copy, PartialEq, Eq)]
328pub enum MatchFlag {
329 TaskOverdue,
331 TaskInProgress,
333 TaskUnstarted,
335 EventSpan,
337 CreatedAt,
338 UpdatedAt,
339 ParentOfMatch,
342}
343
344impl MatchFlag {
345 pub fn as_str(self) -> &'static str {
346 match self {
347 MatchFlag::TaskOverdue => "task_overdue",
348 MatchFlag::TaskInProgress => "task_in_progress",
349 MatchFlag::TaskUnstarted => "task_unstarted",
350 MatchFlag::EventSpan => "event_span",
351 MatchFlag::CreatedAt => "created",
352 MatchFlag::UpdatedAt => "updated",
353 MatchFlag::ParentOfMatch => "parent_of_match",
354 }
355 }
356}
357
358impl serde::Serialize for MatchFlag {
359 fn serialize<S: serde::Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
360 s.serialize_str(self.as_str())
361 }
362}
363
364#[derive(serde::Serialize)]
371pub struct EntryListItem {
372 #[serde(flatten)]
373 pub entry: EntryHeader,
374 #[serde(skip_serializing_if = "Option::is_none")]
375 pub match_flags: Option<Vec<MatchFlag>>,
376}
377
378#[derive(serde::Serialize)]
382pub struct EntryTreeNode {
383 #[serde(flatten)]
384 pub entry: EntryHeader,
385 #[serde(skip_serializing_if = "Vec::is_empty")]
386 pub match_flags: Vec<MatchFlag>,
387 pub children: Vec<EntryTreeNode>,
388}
389
390pub fn build_entry_tree(entries: Vec<(EntryHeader, Vec<MatchFlag>)>) -> Vec<EntryTreeNode> {
398 use std::collections::HashMap;
399
400 let id_index: HashMap<caretta_id::CarettaId, usize> = entries
402 .iter()
403 .enumerate()
404 .map(|(i, (e, _))| (e.frontmatter.id, i))
405 .collect();
406
407 let is_root: Vec<bool> = entries
409 .iter()
410 .map(|(e, _)| {
411 e.frontmatter
412 .parent_id
413 .map_or(true, |pid| !id_index.contains_key(&pid))
414 })
415 .collect();
416
417 let mut children_of: Vec<Vec<usize>> = vec![Vec::new(); entries.len()];
419 for (i, (e, _)) in entries.iter().enumerate() {
420 if let Some(pid) = e.frontmatter.parent_id {
421 if let Some(&parent_i) = id_index.get(&pid) {
422 children_of[parent_i].push(i);
423 }
424 }
425 }
426
427 let mut slots: Vec<Option<(EntryHeader, Vec<MatchFlag>)>> =
430 entries.into_iter().map(Some).collect();
431
432 fn build_node(
433 idx: usize,
434 slots: &mut Vec<Option<(EntryHeader, Vec<MatchFlag>)>>,
435 children_of: &Vec<Vec<usize>>,
436 ) -> EntryTreeNode {
437 let (entry, labels) = slots[idx].take().unwrap();
438 let children = children_of[idx]
439 .iter()
440 .map(|&ci| build_node(ci, slots, children_of))
441 .collect();
442 EntryTreeNode { entry, match_flags: labels, children }
443 }
444
445 (0..slots.len())
446 .filter(|&i| is_root[i])
447 .map(|i| build_node(i, &mut slots, &children_of))
448 .collect()
449}
450
451pub fn fill_ancestor_entries(
462 mut filtered: Vec<(EntryHeader, Vec<MatchFlag>)>,
463 state: &JournalState,
464) -> Result<Vec<(EntryHeader, Vec<MatchFlag>)>> {
465 use std::collections::{HashMap, HashSet};
466
467 if filtered.is_empty() {
468 return Ok(filtered);
469 }
470
471 let present: HashSet<CarettaId> = filtered.iter().map(|(e, _)| e.frontmatter.id).collect();
473
474 let all_map: HashMap<CarettaId, EntryHeader> = {
476 let all = cache::list_entries_from_cache(&state.conn).unwrap_or_else(|_| {
477 state.journal
478 .collect_entries()
479 .unwrap_or_default()
480 .iter()
481 .filter_map(|p| read_entry(p).ok().map(EntryHeader::from))
482 .collect()
483 });
484 all.into_iter().map(|e| (e.frontmatter.id, e)).collect()
485 };
486
487 let mut to_add: IndexMap<CarettaId, EntryHeader> = IndexMap::new();
491 for (entry, _) in &filtered {
492 let mut current_pid = entry.frontmatter.parent_id;
493 while let Some(pid) = current_pid {
494 if present.contains(&pid) || to_add.contains_key(&pid) {
495 break;
496 }
497 match all_map.get(&pid) {
498 Some(parent) => {
499 let next = parent.frontmatter.parent_id;
500 to_add.insert(pid, parent.clone());
501 current_pid = next;
502 }
503 None => break,
504 }
505 }
506 }
507
508 for (_, ancestor) in to_add {
509 filtered.push((ancestor, vec![MatchFlag::ParentOfMatch]));
510 }
511
512 Ok(filtered)
513}
514
515pub fn list_entries(
525 state: &JournalState,
526 filter: &EntryFilter,
527) -> Result<Vec<(EntryHeader, Vec<MatchFlag>)>> {
528 let _ = cache::sync_cache(&state.journal, &state.conn);
529 if let Ok(entries) = cache::list_entries_from_cache(&state.conn) {
530 return apply_filter_and_sort(entries, filter);
531 }
532 let entries: Vec<EntryHeader> = state.journal
534 .collect_entries()?
535 .iter()
536 .filter_map(|p| match read_entry(p) {
537 Ok(e) => Some(EntryHeader::from(e)),
538 Err(e) => { eprintln!("warn: {} — {e}", p.display()); None }
539 })
540 .collect();
541 apply_filter_and_sort(entries, filter)
542}
543
544fn apply_filter_and_sort(entries: Vec<EntryHeader>, filter: &EntryFilter) -> Result<Vec<(EntryHeader, Vec<MatchFlag>)>> {
545 let has_filter = filter.has_any_filter();
546 let mut result = Vec::new();
547 for entry in entries {
548 let (include, labels) = filter.matches(&entry);
549 if has_filter && !include {
550 continue;
551 }
552 result.push((entry, labels));
553 }
554 if filter.sort_by != SortField::Unsorted {
555 result.sort_by(|(a, _), (b, _)| {
556 let ord = sort_cmp(a, b, filter.sort_by);
557 if filter.sort_order == SortOrder::Desc { ord.reverse() } else { ord }
558 });
559 }
560 Ok(result)
561}
562
563fn sort_cmp(a: &EntryHeader, b: &EntryHeader, field: SortField) -> Ordering {
564 match field {
565 SortField::Unsorted => Ordering::Equal,
566 SortField::Id => a.id().cmp(&b.id()),
567 SortField::Title => a.title().cmp(b.title()),
568 SortField::TaskStatus => {
569 let sa = a.frontmatter.task.as_ref().map(|t| t.status.as_str()).unwrap_or("");
570 let sb = b.frontmatter.task.as_ref().map(|t| t.status.as_str()).unwrap_or("");
571 sa.cmp(sb)
572 }
573 SortField::CreatedAt => a.frontmatter.created_at.cmp(&b.frontmatter.created_at),
574 SortField::UpdatedAt => a.frontmatter.updated_at.cmp(&b.frontmatter.updated_at),
575 SortField::TaskDue => cmp_opt(
576 a.frontmatter.task.as_ref().and_then(|t| t.due),
577 b.frontmatter.task.as_ref().and_then(|t| t.due),
578 ),
579 SortField::EventStart => cmp_opt(
580 a.frontmatter.event.as_ref().map(|e| e.start),
581 b.frontmatter.event.as_ref().map(|e| e.start),
582 ),
583 }
584}
585
586fn cmp_opt<T: Ord>(a: Option<T>, b: Option<T>) -> Ordering {
588 match (a, b) {
589 (Some(x), Some(y)) => x.cmp(&y),
590 (Some(_), None) => Ordering::Less,
591 (None, Some(_)) => Ordering::Greater,
592 (None, None) => Ordering::Equal,
593 }
594}
595
596
597#[derive(Debug, Default)]
605pub struct EntryFields {
606 pub title: Option<String>,
607 pub body: Option<String>,
608 pub parent: UpdateOption<EntryRef>,
612 pub slug: Option<String>,
613 pub tags: Option<Vec<String>>,
615 pub task_due: Option<NaiveDateTime>,
616 pub task_status: Option<String>,
617 pub task_started_at: Option<NaiveDateTime>,
618 pub task_closed_at: Option<NaiveDateTime>,
619 pub event_start: Option<NaiveDateTime>,
620 pub event_end: Option<NaiveDateTime>,
621}
622
623pub fn create_entry(state: &JournalState, fields: EntryFields) -> Result<PathBuf> {
637 let journal = &state.journal;
638 let conn = &state.conn;
639 let id = CarettaId::now_unix();
640 let year = chrono::Local::now().year();
641
642 let title = fields.title.unwrap_or_default();
643 let body = fields.body.unwrap_or_default();
644
645 if !title.is_empty() {
647 let dup_policy = journal.config().unwrap_or_default().journal.duplicate_title;
648 if dup_policy != DuplicateTitlePolicy::Allow {
649 let count: i64 = conn.query_row(
650 "SELECT COUNT(*) FROM entries WHERE title = ?1",
651 [&title],
652 |row| row.get(0),
653 )?;
654 if count > 0 {
655 match dup_policy {
656 DuplicateTitlePolicy::Warn => {
657 eprintln!("warn: duplicate title detected: `{title}`");
658 }
659 DuplicateTitlePolicy::Error => {
660 return Err(Error::DuplicateTitle(title.clone()));
661 }
662 DuplicateTitlePolicy::Allow => unreachable!(),
663 }
664 }
665 }
666 }
667
668 let parent_id = match &fields.parent {
670 UpdateOption::Set(r) => resolve_parent_id(conn, Some(r))?,
671 UpdateOption::Clear | UpdateOption::Unchanged => None,
672 };
673
674 let tags = fields.tags.unwrap_or_default();
675
676 let task = if fields.task_due.is_some()
677 || fields.task_status.is_some()
678 || fields.task_started_at.is_some()
679 || fields.task_closed_at.is_some()
680 {
681 let status = fields.task_status.unwrap_or_else(|| "open".to_owned());
682 let inactive = matches!(status.as_str(), "done" | "cancelled" | "archived");
683 let in_progress = status == "in_progress";
684 let started_at = fields
685 .task_started_at
686 .or_else(|| in_progress.then(|| chrono::Local::now().naive_local()));
687 let closed_at = fields
688 .task_closed_at
689 .or_else(|| inactive.then(|| chrono::Local::now().naive_local()));
690 Some(TaskMeta { due: fields.task_due, status, started_at, closed_at, extra: IndexMap::new() })
691 } else {
692 None
693 };
694
695 let event = if fields.event_start.is_some() || fields.event_end.is_some() {
696 let start = fields.event_start.or(fields.event_end).unwrap();
697 let end = fields.event_end.or(fields.event_start).unwrap();
698 Some(EventMeta { start, end, extra: IndexMap::new() })
699 } else {
700 None
701 };
702
703 let now = chrono::Local::now().naive_local();
704 let frontmatter = Frontmatter {
705 id,
706 parent_id,
707 title,
708 slug: fields.slug.unwrap_or_default(),
709 tags,
710 created_at: now,
711 updated_at: now,
712 task,
713 event,
714 extra: IndexMap::new(),
715 };
716
717 let dest = journal.entries_root()?
718 .join(year.to_string())
719 .join(entry_filename_from_frontmatter(id, &frontmatter));
720 if dest.exists() {
721 return Err(Error::EntryAlreadyExists(dest.display().to_string()));
722 }
723
724 let entry = Entry { path: dest.clone(), frontmatter, body };
725
726 std::fs::create_dir_all(dest.parent().unwrap())?;
727 std::fs::write(&dest, render_entry(&entry))?;
728 Ok(dest)
729}
730
731pub fn update_entry(path: &Path, conn: &Connection, fields: EntryFields) -> Result<Option<PathBuf>> {
743 let mut entry = read_entry(path)?;
744
745 if let Some(t) = fields.title {
746 if t != entry.frontmatter.title && !t.is_empty() {
748 let count: i64 = conn.query_row(
749 "SELECT COUNT(*) FROM entries WHERE title = ?1 AND id != ?2",
750 rusqlite::params![t, entry.frontmatter.id],
751 |row| row.get(0),
752 )?;
753 if count > 0 {
754 return Err(Error::DuplicateTitle(t));
755 }
756 }
757 entry.frontmatter.title = t;
758 }
759 if let Some(b) = fields.body {
760 entry.body = b;
761 }
762 match &fields.parent {
763 UpdateOption::Set(r) => {
764 entry.frontmatter.parent_id = Some(resolve_parent_id(conn, Some(r))?.unwrap());
765 }
766 UpdateOption::Clear => {
767 entry.frontmatter.parent_id = None;
768 }
769 UpdateOption::Unchanged => {}
770 }
771 if let Some(s) = fields.slug {
772 entry.frontmatter.slug = s;
773 }
774 if let Some(ts) = fields.tags {
775 entry.frontmatter.tags = ts;
776 }
777
778 if fields.task_due.is_some()
779 || fields.task_status.is_some()
780 || fields.task_started_at.is_some()
781 || fields.task_closed_at.is_some()
782 {
783 let task = entry.frontmatter.task.get_or_insert_with(|| TaskMeta {
784 status: "open".to_owned(),
785 due: None,
786 started_at: None,
787 closed_at: None,
788 extra: IndexMap::new(),
789 });
790 if let Some(d) = fields.task_due {
791 task.due = Some(d);
792 }
793 if let Some(s) = fields.task_status {
794 let in_progress = s == "in_progress";
795 let inactive = matches!(s.as_str(), "done" | "cancelled" | "archived");
796 task.status = s;
797 if in_progress && task.started_at.is_none() && fields.task_started_at.is_none() {
798 task.started_at = Some(chrono::Local::now().naive_local());
799 }
800 if inactive && task.closed_at.is_none() && fields.task_closed_at.is_none() {
801 task.closed_at = Some(chrono::Local::now().naive_local());
802 }
803 }
804 if let Some(sa) = fields.task_started_at {
805 task.started_at = Some(sa);
806 }
807 if let Some(ca) = fields.task_closed_at {
808 task.closed_at = Some(ca);
809 }
810 }
811
812 if fields.event_start.is_some() || fields.event_end.is_some() {
813 let event = entry.frontmatter.event.get_or_insert_with(|| {
814 let start = fields.event_start.or(fields.event_end).unwrap();
815 let end = fields.event_end.or(fields.event_start).unwrap();
816 EventMeta { start, end, extra: IndexMap::new() }
817 });
818 if let Some(s) = fields.event_start {
819 event.start = s;
820 }
821 if let Some(e) = fields.event_end {
822 event.end = e;
823 }
824 }
825
826 fix_entry_mut(&mut entry)
827}
828
829pub fn resolve_parent_id(conn: &Connection, parent: Option<&EntryRef>) -> Result<Option<CarettaId>> {
834 match parent {
835 None => Ok(None),
836 Some(EntryRef::Id(id)) => Ok(Some(*id)),
837 Some(EntryRef::Path(path)) => Ok(Some(read_entry(path)?.frontmatter.id)),
838 Some(EntryRef::Title(title)) => {
839 Ok(Some(cache::find_entry_by_title(conn, title)?.frontmatter.id))
840 }
841 }
842}
843
844pub fn prepare_new_entry(journal: &Journal, parent_id: Option<CarettaId>) -> Result<PathBuf> {
854 let id = CarettaId::now_unix();
855 let year = chrono::Local::now().year();
856 let now = chrono::Local::now().naive_local();
857 let now_fmt = now.format("%Y-%m-%dT%H:%M");
858
859 let dir = journal.entries_root()?.join(year.to_string());
860 std::fs::create_dir_all(&dir)?;
861
862 let path = dir.join(format!("{id}.md"));
863
864 let parent_line = match parent_id {
865 Some(pid) => format!("parent_id: '{pid}'\n"),
866 None => String::new(),
867 };
868
869 let template = format!(
870 "---\n\
871 id: '{id}'\n\
872 {parent_line}\
873 title: ''\n\
874 created_at: {now_fmt}\n\
875 updated_at: {now_fmt}\n\
876 # slug: ''\n\
877 # tags: [tag1, tag2]\n\
878 # task:\n\
879 # status: open\n\
880 # due: YYYY-MM-DD\n\
881 # event:\n\
882 # start: YYYY-MM-DD\n\
883 # end: YYYY-MM-DD\n\
884 ---\n\n"
885 );
886
887 std::fs::write(&path, template)?;
888 Ok(path)
889}
890
891pub fn resolve_entry(entry_ref: &EntryRef, conn: &Connection) -> Result<PathBuf> {
900 match entry_ref {
901 EntryRef::Path(p) => Ok(p.clone()),
902 EntryRef::Id(id) => cache::find_entry_by_id(conn, *id).map(|e| e.path),
903 EntryRef::Title(title) => cache::find_entry_by_title(conn, title).map(|e| e.path),
904 }
905}
906
907#[derive(Debug, Clone)]
911pub enum CheckIssue {
912 FilenameMismatch {
914 expected_filename: String,
916 },
917}
918
919impl CheckIssue {
920 pub fn as_str(&self) -> String {
921 match self {
922 CheckIssue::FilenameMismatch { expected_filename } =>
923 format!("filename mismatch — should be `{expected_filename}`"),
924 }
925 }
926}
927
928pub fn check_entry(path: &Path) -> Result<Vec<CheckIssue>> {
935 let entry = read_entry(path)?;
936 let expected = entry_filename_from_frontmatter(entry.frontmatter.id, &entry.frontmatter);
937 let actual = path.file_name().and_then(|s| s.to_str()).unwrap_or_default();
938
939 let mut issues = Vec::new();
940 if actual != expected {
941 issues.push(CheckIssue::FilenameMismatch { expected_filename: expected });
942 }
943 Ok(issues)
944}
945
946fn sync_started_at(entry: &mut Entry) {
950 if let Some(task) = &mut entry.frontmatter.task {
951 if task.status == "in_progress" && task.started_at.is_none() {
952 task.started_at = Some(chrono::Local::now().naive_local());
953 }
954 }
955}
956
957fn sync_closed_at(entry: &mut Entry) {
959 if let Some(task) = &mut entry.frontmatter.task {
960 let is_closed = matches!(task.status.as_str(), "done" | "cancelled" | "archived");
961 if is_closed && task.closed_at.is_none() {
962 task.closed_at = Some(chrono::Local::now().naive_local());
963 }
964 }
965}
966
967fn fix_entry_mut(entry: &mut Entry) -> Result<Option<PathBuf>> {
972 sync_started_at(entry);
973 sync_closed_at(entry);
974 entry.frontmatter.updated_at = chrono::Local::now().naive_local();
975 std::fs::write(&entry.path, render_entry(entry))?;
976
977 let expected = entry_filename_from_frontmatter(entry.frontmatter.id, &entry.frontmatter);
978 let path = entry.path.clone();
979 let current_dir = path.parent().unwrap_or_else(|| Path::new("."));
980
981 let expected_dir = if let Some(journal_root) = current_dir.parent() {
985 let current_year_name = current_dir
986 .file_name()
987 .and_then(|n| n.to_str())
988 .unwrap_or("");
989 if current_year_name.len() == 4 && current_year_name.chars().all(|c| c.is_ascii_digit()) {
990 let year = entry.frontmatter.created_at.year();
991 journal_root.join(year.to_string())
992 } else {
993 current_dir.to_path_buf()
994 }
995 } else {
996 current_dir.to_path_buf()
997 };
998
999 let new_path = expected_dir.join(&expected);
1000
1001 if path == new_path {
1002 return Ok(None);
1003 }
1004
1005 std::fs::create_dir_all(&expected_dir)?;
1006 std::fs::rename(&path, &new_path)?;
1007 Ok(Some(new_path))
1008}
1009
1010pub fn fix_entry(path: &Path) -> Result<Option<PathBuf>> {
1016 let mut entry = read_entry(path)?;
1017 fix_entry_mut(&mut entry)
1018}
1019
1020pub fn remove_entry(path: &Path) -> Result<()> {
1024 std::fs::remove_file(path).map_err(Error::Io)
1025}
1026
1027pub(crate) fn entry_filename_from_frontmatter(id: CarettaId, fm: &Frontmatter) -> String {
1032 let slug = if !fm.slug.is_empty() {
1033 fm.slug.clone()
1034 } else if fm.title.is_empty() {
1035 String::new()
1036 } else {
1037 slugify(&fm.title)
1038 };
1039 if slug.is_empty() {
1040 format!("{id}.md")
1041 } else {
1042 format!("{id}_{slug}.md")
1043 }
1044}