git.lucas.co / cce-vault
notes vault library and CLI (Obsidian-compatible)
git clone https://git.lucas.co/cce-vault.git

src/daily.rs (10.7K)

  1 //! Daily notes, compatible with Obsidian's core plugin: the folder, file
  2 //! name format and template come from `.obsidian/daily-notes.json`, and the
  3 //! format is a moment.js format string (`YYYY-MM-DD`, `YYYY/MM/DD dddd`),
  4 //! so a vault shared with Obsidian finds the same file for the same day.
  5 //!
  6 //! Templates expand the core Templates plugin's variables: `{{title}}`,
  7 //! `{{date}}`, `{{time}}`, and `{{date:FORMAT}}` / `{{time:FORMAT}}`.
  8 
  9 use chrono::{Datelike, Local, NaiveDate, NaiveDateTime, Timelike};
 10 use serde::Deserialize;
 11 
 12 use crate::index::{FileKind, Index};
 13 use crate::write::{atomic_write, WriteError};
 14 
 15 pub const DEFAULT_FORMAT: &str = "YYYY-MM-DD";
 16 
 17 #[derive(Debug, Clone, Default, PartialEq, Deserialize)]
 18 pub struct DailyConfig {
 19     #[serde(default)]
 20     pub folder: String,
 21     #[serde(default)]
 22     pub format: String,
 23     #[serde(default)]
 24     pub template: String,
 25 }
 26 
 27 impl DailyConfig {
 28     /// Obsidian's settings for this vault, or its defaults (vault root,
 29     /// `YYYY-MM-DD`, no template) when there are none.
 30     pub fn load(index: &Index) -> DailyConfig {
 31         let path = index.root().join(".obsidian/daily-notes.json");
 32         let mut cfg: DailyConfig = std::fs::read_to_string(path)
 33             .ok()
 34             .and_then(|t| serde_json::from_str(&t).ok())
 35             .unwrap_or_default();
 36         cfg.folder = cfg.folder.trim().trim_matches('/').to_string();
 37         if cfg.format.trim().is_empty() {
 38             cfg.format = DEFAULT_FORMAT.to_string();
 39         }
 40         cfg
 41     }
 42 
 43     /// The vault path of `date`'s note.
 44     pub fn path_for(&self, date: NaiveDate) -> String {
 45         let name = format_moment(&date.and_hms_opt(0, 0, 0).unwrap(), &self.format);
 46         if self.folder.is_empty() {
 47             format!("{name}.md")
 48         } else {
 49             format!("{}/{name}.md", self.folder)
 50         }
 51     }
 52 }
 53 
 54 impl Index {
 55     /// `date`'s daily note: its path, and whether this call created it.
 56     /// With `create` unset, only the path is computed.
 57     pub fn daily(&mut self, date: NaiveDate, create: bool) -> Result<(String, bool), WriteError> {
 58         let cfg = DailyConfig::load(self);
 59         let path = cfg.path_for(date);
 60         if !create || self.entry(&path).is_some() || self.abs(&path).exists() {
 61             return Ok((path, false));
 62         }
 63         let body = self.template_text(&cfg.template).unwrap_or_default();
 64         let now = Local::now().naive_local();
 65         let at = date.and_hms_opt(now.hour(), now.minute(), now.second()).unwrap();
 66         let title = crate::index::stem(&path).to_string();
 67         let text = expand_template(&body, &title, &at, &cfg.format);
 68         atomic_write(&self.abs(&path), text.as_bytes())?;
 69         self.refresh(std::slice::from_ref(&path));
 70         Ok((path, true))
 71     }
 72 
 73     fn template_text(&self, template: &str) -> Option<String> {
 74         let t = template.trim().trim_start_matches('/');
 75         if t.is_empty() {
 76             return None;
 77         }
 78         let rel = if FileKind::of(t) == FileKind::Note { t.to_string() } else { format!("{t}.md") };
 79         std::fs::read_to_string(self.abs(&rel)).ok()
 80     }
 81 }
 82 
 83 /// Expand `{{title}}`, `{{date}}`, `{{time}}`, `{{date:FMT}}` and
 84 /// `{{time:FMT}}`. Unknown `{{…}}` are left as written.
 85 pub fn expand_template(body: &str, title: &str, at: &NaiveDateTime, date_format: &str) -> String {
 86     let mut out = String::with_capacity(body.len());
 87     let mut rest = body;
 88     while let Some(open) = rest.find("{{") {
 89         out.push_str(&rest[..open]);
 90         let after = &rest[open + 2..];
 91         let Some(close) = after.find("}}") else {
 92             out.push_str(&rest[open..]);
 93             return out;
 94         };
 95         let inner = after[..close].trim();
 96         let (name, fmt) = match inner.split_once(':') {
 97             Some((n, f)) => (n.trim(), Some(f.trim())),
 98             None => (inner, None),
 99         };
100         match name.to_ascii_lowercase().as_str() {
101             "title" => out.push_str(title),
102             "date" => out.push_str(&format_moment(at, fmt.unwrap_or(date_format))),
103             "time" => out.push_str(&format_moment(at, fmt.unwrap_or("HH:mm"))),
104             _ => out.push_str(&rest[open..open + 2 + close + 2]),
105         }
106         rest = &after[close + 2..];
107     }
108     out.push_str(rest);
109     out
110 }
111 
112 const MONTHS: [&str; 12] = [
113     "January", "February", "March", "April", "May", "June", "July", "August", "September", "October",
114     "November", "December",
115 ];
116 const DAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
117 
118 /// The moment.js tokens a daily-note format uses, longest first so `MMMM`
119 /// wins over `MM`. `[text]` is literal, as in moment.
120 const TOKENS: [&str; 38] = [
121     "YYYY", "GGGG", "gggg", "MMMM", "DDDD", "dddd", "MMM", "DDD", "ddd", "YY", "MM", "Mo", "DD", "Do",
122     "dd", "do", "WW", "Wo", "ww", "wo", "HH", "hh", "mm", "ss", "Q", "M", "D", "d", "E", "e", "W", "w",
123     "H", "h", "m", "s", "A", "a",
124 ];
125 
126 pub fn format_moment(at: &NaiveDateTime, fmt: &str) -> String {
127     let d = at.date();
128     let mut out = String::new();
129     let mut rest = fmt;
130     'outer: while !rest.is_empty() {
131         if let Some(r) = rest.strip_prefix('[') {
132             let end = r.find(']').unwrap_or(r.len());
133             out.push_str(&r[..end]);
134             rest = r.get(end + 1..).unwrap_or("");
135             continue;
136         }
137         for tok in TOKENS {
138             if let Some(r) = rest.strip_prefix(tok) {
139                 out.push_str(&token(at, d, tok));
140                 rest = r;
141                 continue 'outer;
142             }
143         }
144         let ch = rest.chars().next().unwrap();
145         out.push(ch);
146         rest = &rest[ch.len_utf8()..];
147     }
148     out
149 }
150 
151 fn ordinal(n: u32) -> String {
152     let suffix = match (n % 10, n % 100) {
153         (_, 11..=13) => "th",
154         (1, _) => "st",
155         (2, _) => "nd",
156         (3, _) => "rd",
157         _ => "th",
158     };
159     format!("{n}{suffix}")
160 }
161 
162 fn token(at: &NaiveDateTime, d: NaiveDate, tok: &str) -> String {
163     let dow = d.weekday().num_days_from_sunday();
164     let week = d.iso_week();
165     let h12 = match at.hour() % 12 {
166         0 => 12,
167         h => h,
168     };
169     match tok {
170         "YYYY" => format!("{:04}", d.year()),
171         "YY" => format!("{:02}", d.year().rem_euclid(100)),
172         "GGGG" | "gggg" => format!("{:04}", week.year()),
173         "Q" => ((d.month() - 1) / 3 + 1).to_string(),
174         "MMMM" => MONTHS[d.month0() as usize].to_string(),
175         "MMM" => MONTHS[d.month0() as usize][..3].to_string(),
176         "MM" => format!("{:02}", d.month()),
177         "Mo" => ordinal(d.month()),
178         "M" => d.month().to_string(),
179         "DDDD" => format!("{:03}", d.ordinal()),
180         "DDD" => d.ordinal().to_string(),
181         "DD" => format!("{:02}", d.day()),
182         "Do" => ordinal(d.day()),
183         "D" => d.day().to_string(),
184         "dddd" => DAYS[dow as usize].to_string(),
185         "ddd" => DAYS[dow as usize][..3].to_string(),
186         "dd" => DAYS[dow as usize][..2].to_string(),
187         "do" => ordinal(dow),
188         "d" | "e" => dow.to_string(),
189         "E" => d.weekday().number_from_monday().to_string(),
190         // Locale weeks (`w`) are taken as ISO weeks, which is what an
191         // en-GB or ISO-configured Obsidian shows.
192         "WW" | "ww" => format!("{:02}", week.week()),
193         "Wo" | "wo" => ordinal(week.week()),
194         "W" | "w" => week.week().to_string(),
195         "HH" => format!("{:02}", at.hour()),
196         "H" => at.hour().to_string(),
197         "hh" => format!("{h12:02}"),
198         "h" => h12.to_string(),
199         "mm" => format!("{:02}", at.minute()),
200         "m" => at.minute().to_string(),
201         "ss" => format!("{:02}", at.second()),
202         "s" => at.second().to_string(),
203         "A" => if at.hour() < 12 { "AM" } else { "PM" }.to_string(),
204         "a" => if at.hour() < 12 { "am" } else { "pm" }.to_string(),
205         _ => tok.to_string(),
206     }
207 }
208 
209 #[cfg(test)]
210 mod tests {
211     use super::*;
212 
213     fn at(y: i32, m: u32, d: u32, h: u32, min: u32) -> NaiveDateTime {
214         NaiveDate::from_ymd_opt(y, m, d).unwrap().and_hms_opt(h, min, 5).unwrap()
215     }
216 
217     #[test]
218     fn moment_formats() {
219         let t = at(2026, 9, 30, 14, 7);
220         assert_eq!(format_moment(&t, "YYYY-MM-DD"), "2026-09-30");
221         assert_eq!(format_moment(&t, "dddd, MMMM Do YYYY"), "Wednesday, September 30th 2026");
222         assert_eq!(format_moment(&t, "YYYY/MM/YYYY-MM-DD ddd"), "2026/09/2026-09-30 Wed");
223         assert_eq!(format_moment(&t, "[Week] W, gggg"), "Week 40, 2026");
224         assert_eq!(format_moment(&t, "h:mm A, HH:mm:ss"), "2:07 PM, 14:07:05");
225         assert_eq!(format_moment(&t, "DDDD Q E d"), "273 3 3 3");
226         assert_eq!(format_moment(&at(2026, 1, 1, 0, 0), "Do MMM hh a"), "1st Jan 12 am");
227         assert_eq!(format_moment(&at(2026, 1, 12, 0, 0), "Do"), "12th");
228     }
229 
230     #[test]
231     fn templates() {
232         let t = at(2026, 9, 30, 9, 5);
233         let body = "# {{title}}\nCreated {{date}} {{time}}\n{{date:dddd}} {{ time : h A }} {{other}} {{";
234         assert_eq!(
235             expand_template(body, "2026-09-30", &t, "YYYY-MM-DD"),
236             "# 2026-09-30\nCreated 2026-09-30 09:05\nWednesday 9 AM {{other}} {{"
237         );
238     }
239 
240     #[test]
241     fn daily_note_from_obsidian_settings() {
242         let dir = tempfile::tempdir().unwrap();
243         let root = dir.path();
244         std::fs::create_dir_all(root.join(".obsidian")).unwrap();
245         std::fs::write(
246             root.join(".obsidian/daily-notes.json"),
247             r#"{"folder":"Journal/","format":"YYYY/MM-DD ddd","template":"Templates/Day"}"#,
248         )
249         .unwrap();
250         std::fs::create_dir_all(root.join("Templates")).unwrap();
251         std::fs::write(root.join("Templates/Day.md"), "# {{date:dddd}}\n[[{{date:YYYY-[W]WW}}]]\n").unwrap();
252         let mut ix = Index::open(root, false).unwrap();
253         let day = NaiveDate::from_ymd_opt(2026, 9, 30).unwrap();
254         assert_eq!(ix.daily(day, false).unwrap(), ("Journal/2026/09-30 Wed.md".to_string(), false));
255         assert_eq!(ix.daily(day, true).unwrap(), ("Journal/2026/09-30 Wed.md".to_string(), true));
256         assert_eq!(
257             std::fs::read_to_string(root.join("Journal/2026/09-30 Wed.md")).unwrap(),
258             "# Wednesday\n[[2026-W40]]\n"
259         );
260         assert!(!ix.daily(day, true).unwrap().1);
261         // The new note's link is indexed. (The template's own
262         // `[[{{date:…}}]]` is a note too, and unresolved, as in Obsidian.)
263         assert!(ix.unresolved().contains_key("2026-w40"));
264     }
265 
266     #[test]
267     fn defaults_without_settings() {
268         let dir = tempfile::tempdir().unwrap();
269         let mut ix = Index::open(dir.path(), false).unwrap();
270         let day = NaiveDate::from_ymd_opt(2026, 1, 2).unwrap();
271         let (path, created) = ix.daily(day, true).unwrap();
272         assert_eq!((path.as_str(), created), ("2026-01-02.md", true));
273         assert_eq!(std::fs::read_to_string(dir.path().join(path)).unwrap(), "");
274     }
275 }