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 }