From e3ab610bcfc1726c04231672232461fda93fd1fa Mon Sep 17 00:00:00 2001 From: "Joakim L. Engeset" Date: Wed, 23 Sep 2026 12:57:04 +0200 Subject: [PATCH] Add `note daily`, and file new notes under an inbox without a date `cid note daily` opens `<[note] daily>/YYYY-MM-DD.md`, `daily` unless set, creating it the first time that day with `created:` front matter and a `# Daily - YYYY-MM-DD` heading. A later run the same day opens the same note; the file is created with create_new, so it is never written over. A `note-daily` action binds it, suggested on ctrl-f10, which fish leaves free. `cid note new` now names a note for its title alone and puts it under `[note] inbox`, `inbox` unless set. The date moves into the note's front matter as `created:`, which the listing already reads. Both settings take a path below the vault, subdirectories included, and both have starter-config and `config print` rows. `note cleanup` offered any note whose name holds no letter as unnamed, which would have listed every daily note; notes under the daily directory are exempt from that rule, though an empty one still shows. Release: minor --- CHANGELOG.md | 15 ++++++ src/binding.rs | 9 +++- src/cmd/config.rs | 22 +++++++- src/cmd/note.rs | 97 ++++++++++++++++++++++++--------- src/config.rs | 16 ++++++ src/main.rs | 18 +++++-- src/note.rs | 133 ++++++++++++++++++++++++++++++++-------------- tests/cli.rs | 68 ++++++++++++++++++++---- 8 files changed, 295 insertions(+), 83 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc1980e..ffe7aaf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,21 @@ raised, never by hand. ## Unreleased +### Added + +- **`cid note daily`** opens today's note, `daily/2026-09-23.md`, starting it + with a `# Daily - 2026-09-23` heading the first time it runs that day. + `[note] daily` moves the folder, and the `note-daily` action binds it — + the starter config suggests `ctrl-f10`. `note cleanup` no longer offers a + daily note just because its name is a date. + +### Changed + +- **`cid note new`** drops the date from the filename: `Migrate to AWS` is now + `inbox/migrate-to-aws.md`. Notes go under `[note] inbox`, `inbox` unless set, + and the date moves into a `created:` line in the note's front matter, which + `note ls` and the selector already read. + ## v0.23.0 *Released 2026-09-23* diff --git a/src/binding.rs b/src/binding.rs index bcb92d1..2ec49a1 100644 --- a/src/binding.rs +++ b/src/binding.rs @@ -88,6 +88,12 @@ pub const ACTIONS: &[Action] = &[ args: &["note", "open"], kind: Kind::Run, }, + Action { + id: "note-daily", + description: "Open today's note, starting it if there is none yet", + args: &["note", "daily"], + kind: Kind::Run, + }, Action { id: "branch-checkout", description: "Select a git branch and check it out", @@ -164,7 +170,7 @@ pub const ACTIONS: &[Action] = &[ /// because users' own tools cluster there. ctrl-f7 displaces nothing in fish, /// but macOS takes Control-F7 for "Change the way Tab moves focus" until that /// shortcut is turned off under System Settings → Keyboard → Keyboard -/// Shortcuts → Keyboard. +/// Shortcuts → Keyboard. ctrl-f10 displaces nothing in fish either. pub const EXAMPLE_BINDINGS: &[(&str, &str)] = &[ ("ctrl-o", "repo-cd"), ("ctrl-t", "worktree-cd"), @@ -175,6 +181,7 @@ pub const EXAMPLE_BINDINGS: &[(&str, &str)] = &[ ("f7", "pr-checkout"), ("ctrl-f7", "pr-worktree-cd"), ("f10", "note-edit"), + ("ctrl-f10", "note-daily"), ("ctrl-r", "history-select"), ("up", "history-up"), ]; diff --git a/src/cmd/config.rs b/src/cmd/config.rs index a32de19..8253a98 100644 --- a/src/cmd/config.rs +++ b/src/cmd/config.rs @@ -88,6 +88,15 @@ ignore = ["node_modules", "target"] # the root; its directory is created the first time. # scratch = "scratch/scratch.md" +# Where `note new` starts a note, as a directory below the root; subdirectories +# are fine. Created the first time. +# inbox = "inbox" + +# Where `note daily` keeps one note per day, named `YYYY-MM-DD.md`, as a +# directory below the root. `note cleanup` does not hold a name made only of a +# date against a note in it. +# daily = "daily" + # What `note open` launches, split on whitespace like $EDITOR. Its own setting # because a note is as often read as written — `glow` and `nvim` are both # answers. Unset, it is $VISUAL then $EDITOR, as `cid edit` uses. @@ -127,6 +136,7 @@ ignore = ["node_modules", "target"] # f7 = "pr-checkout" # check out a pull request # ctrl-f7 = "pr-worktree-cd" # the same, in a worktree of its own, and cd there # f10 = "note-edit" # open a note from the vault +# ctrl-f10 = "note-daily" # open today's note, starting it if need be # ctrl-r = "history-select" # search history onto the command line # up = "history-up" # the same, on the first line of a prompt @@ -404,6 +414,15 @@ fn report(cfg: &Config, env: &Env) -> Vec { DEFAULT, ), }); + for (key, set, default) in [ + ("inbox", &cfg.note.inbox, crate::note::DEFAULT_INBOX), + ("daily", &cfg.note.daily, crate::note::DEFAULT_DAILY), + ] { + rows.push(match set.as_deref() { + Some(dir) => Row::setting(key, Value::Set(dir.to_string()), ""), + None => Row::setting(key, Value::Set(default.to_string()), DEFAULT), + }); + } rows.push(match (cfg.note.editor.as_deref(), env.editor) { (Some(editor), _) => Row::setting("editor", Value::Set(editor.to_string()), ""), (None, Some(editor)) => Row::setting( @@ -1642,7 +1661,8 @@ mod tests { let sound = shell_check(&sound_config); assert_eq!(sound.status, Status::Ok); - assert!(sound.detail.contains("11 key bindings"), "{}", sound.detail); + let bound = format!("{} key bindings", binding::EXAMPLE_BINDINGS.len()); + assert!(sound.detail.contains(&bound), "{}", sound.detail); // Nothing written is nothing bound, and that is a sound state. let empty = shell_check(&Config::default()); diff --git a/src/cmd/note.rs b/src/cmd/note.rs index 6a5df3c..249d1e5 100644 --- a/src/cmd/note.rs +++ b/src/cmd/note.rs @@ -312,44 +312,88 @@ fn item( /// `cid note new [TITLE]` — start a note called `TITLE` and open it. /// -/// With no title, one is asked for, with the filename it will produce drawn -/// beneath it as it is typed. The note is `YYYY-MM-DD-.md` -/// at the top of the vault, and starts as the title for its H1 — which leaves -/// a note abandoned there a heading and nothing else, what `note cleanup` -/// offers as empty. +/// With no title, one is asked for, with the path it will produce drawn +/// beneath it as it is typed. The note is `.md` under +/// `[note] inbox`, dated in its front matter and titled with an H1 — which +/// leaves a note abandoned there a heading and nothing else, what `note +/// cleanup` offers as empty. pub fn new(ctx: &Ctx, title: Option<&str>) -> Result<()> { let editor = ctx.note_editor()?; let root = vault(ctx)?; - let (now, offset) = (crate::unix_now(), ctx.utc_offset()); + let inbox = ctx.config.note.inbox_dir(); + let dir = root.join(inbox); let file_for = |title: &str| { - note::free_name(¬e::titled_name(title, now, offset), |candidate| { - root.join(candidate).exists() + note::free_name(¬e::titled_name(title), |candidate| { + dir.join(candidate).exists() }) }; - let title = - match title.map(str::trim) { - Some("") => bail!("a note's title cannot be blank"), - Some(title) => title.to_string(), - None => prompt::ask("Title", "File", file_for, ctx.color()).map_err(|e| { - match e.is::() { - true => e, - false => e.context("no TITLE given and no terminal to ask for one"), - } - })?, - }; - let path = root.join(file_for(&title)); + let title = match title.map(str::trim) { + Some("") => bail!("a note's title cannot be blank"), + Some(title) => title.to_string(), + None => prompt::ask( + "Title", + "File", + |title| format!("{}/{}", inbox.trim_end_matches('/'), file_for(title)), + ctx.color(), + ) + .map_err(|e| match e.is::() { + true => e, + false => e.context("no TITLE given and no terminal to ask for one"), + })?, + }; + let path = dir.join(file_for(&title)); + create(&path, ¬e::new_note(&title, &today(ctx)))?; + launch_on(ctx, &editor, &path) +} + +/// `cid note daily` — open today's note, starting it if there is none yet. +/// +/// One note per day, `YYYY-MM-DD.md` under `[note] daily`: a second run on the +/// same day opens the note the first one started. +pub fn daily(ctx: &Ctx) -> Result<()> { + let editor = ctx.note_editor()?; + let root = vault(ctx)?; + let day = today(ctx); + let path = root + .join(ctx.config.note.daily_dir()) + .join(note::daily_name(&day)); + match create(&path, ¬e::new_note(¬e::daily_title(&day), &day)) { + Ok(()) => {} + Err(e) if already_there(&e) => ctx.log.info("today's note is already started"), + Err(e) => return Err(e), + } + launch_on(ctx, &editor, &path) +} +/// Today, `YYYY-MM-DD`, in local time. +fn today(ctx: &Ctx) -> String { + note::date(crate::unix_now(), ctx.utc_offset()) +} + +/// Write `text` to `path` as a new file, making its directory first. Never +/// overwrites: a file already at `path` is an [`std::io::ErrorKind::AlreadyExists`]. +fn create(path: &Path, text: &str) -> Result<()> { + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?; + } std::fs::OpenOptions::new() .write(true) .create_new(true) - .open(&path) - .and_then(|mut file| std::io::Write::write_all(&mut file, note::heading(&title).as_bytes())) - .with_context(|| format!("creating {}", path.display()))?; + .open(path) + .and_then(|mut file| std::io::Write::write_all(&mut file, text.as_bytes())) + .with_context(|| format!("creating {}", path.display())) +} + +fn already_there(e: &anyhow::Error) -> bool { + e.downcast_ref::() + .is_some_and(|io| io.kind() == std::io::ErrorKind::AlreadyExists) +} +fn launch_on(ctx: &Ctx, editor: &[String], path: &Path) -> Result<()> { let target = path.to_string_lossy().into_owned(); - ctx.log.info(&format!("new note at {target}")); - cmd::edit::launch(ctx, &editor, std::slice::from_ref(&target)) + ctx.log.info(&format!("opening {target}")); + cmd::edit::launch(ctx, editor, std::slice::from_ref(&target)) } /// `cid note scratch` — open the one note that is filed nowhere. @@ -485,7 +529,8 @@ fn junk(ctx: &Ctx, notes: &[Note]) -> Result> { let bytes = note.path.metadata().map(|m| m.len()).unwrap_or(0); let text = read_head(¬e.path, CLEANUP_BYTES).unwrap_or_default(); let (_, body) = note::split_front_matter(&text); - note::junk(note, body, &scratch).map(|reason| (note.clone(), reason, bytes)) + note::junk(note, body, &scratch, ctx.config.note.daily_dir()) + .map(|reason| (note.clone(), reason, bytes)) }) .collect(); note::cleanup_order(&mut candidates); diff --git a/src/config.rs b/src/config.rs index be7428d..9411211 100644 --- a/src/config.rs +++ b/src/config.rs @@ -223,6 +223,12 @@ pub struct NoteConfig { /// The one permanent note `note scratch` opens, as a path below /// [`Self::root`]. Unset, it is `scratch/scratch.md`. pub scratch: Option, + /// Where `note new` starts a note, as a directory below [`Self::root`]. + /// Unset, it is `inbox`. + pub inbox: Option, + /// Where `note daily` keeps one note per day, as a directory below + /// [`Self::root`]. Unset, it is `daily`. + pub daily: Option, /// What `note open` launches, split on whitespace like `$EDITOR`. Unset, it /// is `$VISUAL` then `$EDITOR`. /// @@ -233,6 +239,16 @@ pub struct NoteConfig { } impl NoteConfig { + /// [`Self::inbox`], or its default. + pub fn inbox_dir(&self) -> &str { + self.inbox.as_deref().unwrap_or(crate::note::DEFAULT_INBOX) + } + + /// [`Self::daily`], or its default. + pub fn daily_dir(&self) -> &str { + self.daily.as_deref().unwrap_or(crate::note::DEFAULT_DAILY) + } + /// The label the directory `dir` carries, or `None` when it has none. /// Matched case-insensitively, as a directory name is on Darwin. pub fn label_of(&self, dir: &str) -> Option<&str> { diff --git a/src/main.rs b/src/main.rs index adcdd64..8170152 100644 --- a/src/main.rs +++ b/src/main.rs @@ -451,16 +451,23 @@ enum NoteCmd { }, /// Start a note from a title and open it /// - /// With no TITLE, one is asked for, and the filename it makes is shown - /// beneath it as it is typed. The note is created at the top of the vault - /// as `YYYY-MM-DD-.md`, holding the title as its H1 — - /// `Migrate to AWS` becomes `2026-09-23-migrate-to-aws.md`. A name already - /// taken gains `-2`, `-3` and so on. + /// With no TITLE, one is asked for, and the path it makes is shown beneath + /// it as it is typed. The note is created under `[note] inbox` as + /// `.md` — `Migrate to AWS` becomes + /// `inbox/migrate-to-aws.md` — with today's date as `created:` in its front + /// matter and the title as its H1. A name already taken gains `-2`, `-3` + /// and so on. New { /// The note's title; omit to be asked for one #[arg(value_name = "TITLE")] title: Option, }, + /// Open today's note, starting it if there is none yet + /// + /// One note per day, `YYYY-MM-DD.md` under `[note] daily`, started with + /// today's date as `created:` in its front matter and `# Daily - + /// YYYY-MM-DD` as its H1. Run again the same day, it opens that note. + Daily, /// Open the one permanent scratch note /// /// Somewhere to put a thought without first deciding whether it is worth a @@ -1058,6 +1065,7 @@ fn dispatch(ctx: &Ctx, command: Command) -> anyhow::Result<()> { NoteCmd::Sel { all } => cmd::note::sel(ctx, all), NoteCmd::Open { notes, all } => cmd::note::open(ctx, ¬es, all), NoteCmd::New { title } => cmd::note::new(ctx, title.as_deref()), + NoteCmd::Daily => cmd::note::daily(ctx), NoteCmd::Scratch => cmd::note::scratch(ctx), NoteCmd::Cleanup { yes, all } => cmd::note::cleanup(ctx, yes, all), }, diff --git a/src/note.rs b/src/note.rs index ac93348..90da368 100644 --- a/src/note.rs +++ b/src/note.rs @@ -1113,9 +1113,12 @@ mod tests { #[test] fn a_note_with_nothing_in_it_is_a_candidate() { let note = note("thoughts.md", Front::default()); - assert_eq!(junk(¬e, "", scratch()), Some(Junk::Empty)); - assert_eq!(junk(¬e, "# Thoughts\n", scratch()), Some(Junk::Empty)); - assert_eq!(junk(¬e, &body_of(20), scratch()), None); + assert_eq!(junk(¬e, "", scratch(), "daily"), Some(Junk::Empty)); + assert_eq!( + junk(¬e, "# Thoughts\n", scratch(), "daily"), + Some(Junk::Empty) + ); + assert_eq!(junk(¬e, &body_of(20), scratch(), "daily"), None); } /// What an editor calls a file nobody named, and everything it goes on to @@ -1130,7 +1133,7 @@ mod tests { ] { let note = note(name, Front::default()); assert_eq!( - junk(¬e, &body_of(20), scratch()), + junk(¬e, &body_of(20), scratch(), "daily"), Some(Junk::Untitled), "{name}" ); @@ -1138,7 +1141,11 @@ mod tests { // Not every name that begins with the word. for name in ["Untitled thoughts.md", "untitledness.md"] { let note = note(name, Front::default()); - assert_eq!(junk(¬e, &body_of(20), scratch()), None, "{name}"); + assert_eq!( + junk(¬e, &body_of(20), scratch(), "daily"), + None, + "{name}" + ); } } @@ -1147,7 +1154,10 @@ mod tests { #[test] fn a_note_named_only_by_numbers_is_a_candidate_unless_it_says_otherwise() { let jotted = note("2026-08-25 1043.md", Front::default()); - assert_eq!(junk(&jotted, &body_of(20), scratch()), Some(Junk::Unnamed)); + assert_eq!( + junk(&jotted, &body_of(20), scratch(), "daily"), + Some(Junk::Unnamed) + ); let titled = note( "2026-08-25 1043.md", @@ -1156,7 +1166,20 @@ mod tests { ..Front::default() }, ); - assert_eq!(junk(&titled, &body_of(20), scratch()), None); + assert_eq!(junk(&titled, &body_of(20), scratch(), "daily"), None); + } + + #[test] + fn a_daily_note_named_only_by_its_day_is_not_a_candidate_for_that() { + let daily = note("personal/daily/2026-09-23.md", Front::default()); + assert_eq!( + junk(&daily, &body_of(40), scratch(), "personal/daily"), + None + ); + assert_eq!( + junk(&daily, "", scratch(), "personal/daily"), + Some(Junk::Empty) + ); } /// A name is whatever the writer typed, and this repository's owner writes @@ -1200,8 +1223,8 @@ mod tests { let _ = note.shown("/home/me"); let _ = row(¬e); let _ = prefix(¬e, &cfg, 8, utc()); - let _ = junk(¬e, "", scratch()); - let _ = junk(¬e, &"word ".repeat(20), scratch()); + let _ = junk(¬e, "", scratch(), "daily"); + let _ = junk(¬e, &"word ".repeat(20), scratch(), "daily"); let widths = Widths::of(std::slice::from_ref(¬e), &cfg, "/home/me"); let _ = status_row(¬e, &cfg, &widths, utc(), "/home/me"); let _ = junk_row(¬e, Junk::Untitled, 10, &widths); @@ -1216,7 +1239,7 @@ mod tests { for name in ["Prosjektø", "Løsningø", "abcdefgø", "Størrelse"] { let note = note(&format!("{name}.md"), Front::default()); assert_ne!( - junk(¬e, &"word ".repeat(20), scratch()), + junk(¬e, &"word ".repeat(20), scratch(), "daily"), Some(Junk::Untitled), "{name}" ); @@ -1303,12 +1326,12 @@ mod tests { path: scratch().to_path_buf(), ..note("scratch/scratch.md", Front::default()) }; - assert_eq!(junk(&pad, "", scratch()), None); - assert_eq!(junk(&pad, "Untitled", scratch()), None); + assert_eq!(junk(&pad, "", scratch(), "daily"), None); + assert_eq!(junk(&pad, "Untitled", scratch(), "daily"), None); // Its neighbours are offered as usual. let other = note("scratch/other.md", Front::default()); - assert_eq!(junk(&other, "", scratch()), Some(Junk::Empty)); + assert_eq!(junk(&other, "", scratch(), "daily"), Some(Junk::Empty)); } /// Front matter is read character by character too, and a Norwegian tag is @@ -1486,20 +1509,31 @@ const LINE_COLOR: u8 = 3; // --- new notes -------------------------------------------------------------- -/// The file a note called `title` is started in on the day `now` falls on: -/// `YYYY-MM-DD-.md`, in local time. -/// -/// The date leads so a directory listing is a timeline, and the slug follows -/// so the name still says what the note is about. A title with nothing in it a -/// slug can use is named for the day alone. -pub fn titled_name(title: &str, now: i64, offset: time::UtcOffset) -> String { - let day = date(now, offset); +/// Where `note new` puts a note when `[note] inbox` does not say. +pub const DEFAULT_INBOX: &str = "inbox"; + +/// Where `note daily` puts the day's note when `[note] daily` does not say. +pub const DEFAULT_DAILY: &str = "daily"; + +/// The file a note called `title` is started in: its title in kebab case. A +/// title with nothing in it a slug can use is `note.md`. +pub fn titled_name(title: &str) -> String { match slug(title).as_str() { - "" => format!("{day}.md"), - slug => format!("{day}-{slug}.md"), + "" => "note.md".to_string(), + slug => format!("{slug}.md"), } } +/// The file the note for `day`, a `YYYY-MM-DD` date, lives in. +pub fn daily_name(day: &str) -> String { + format!("{day}.md") +} + +/// The title the note for `day` carries. +pub fn daily_title(day: &str) -> String { + format!("Daily - {day}") +} + /// `title` in kebab case: lowercase words joined by `-`. /// /// Anything that is not a letter or a digit separates words, so a `/` in a @@ -1518,9 +1552,13 @@ pub fn slug(title: &str) -> String { .join("-") } -/// What a new note's file starts with: its title, as the one H1. -pub fn heading(title: &str) -> String { - format!("# {}\n", title.trim()) +/// What a new note's file starts with: front matter dating it to `day`, a +/// `YYYY-MM-DD` date, then its title as the one H1. +/// +/// The date lives in the front matter rather than the filename, which is where +/// a listing reads a note's creation date from. +pub fn new_note(title: &str, day: &str) -> String { + format!("---\ncreated: {day}\n---\n\n# {}\n", title.trim()) } /// A name nothing is using yet: `name`, else `name-2`, `name-3` and so on. @@ -1666,17 +1704,19 @@ mod search_tests { // --- new notes --- #[test] - fn a_titled_name_is_the_date_then_the_title_in_kebab_case() { - let sept_23 = 1_790_121_600; - assert_eq!( - titled_name("Migrate to AWS", sept_23, utc()), - "2026-09-23-migrate-to-aws.md" - ); + fn a_titled_name_is_the_title_in_kebab_case() { + assert_eq!(titled_name("Migrate to AWS"), "migrate-to-aws.md"); } #[test] - fn a_title_with_nothing_to_slug_is_named_for_the_day() { - assert_eq!(titled_name(" ?! ", 0, utc()), "1970-01-01.md"); + fn a_title_with_nothing_to_slug_is_still_a_name() { + assert_eq!(titled_name(" ?! "), "note.md"); + } + + #[test] + fn a_daily_note_is_named_and_titled_for_its_day() { + assert_eq!(daily_name("2026-09-23"), "2026-09-23.md"); + assert_eq!(daily_title("2026-09-23"), "Daily - 2026-09-23"); } #[test] @@ -1694,9 +1734,19 @@ mod search_tests { } } + /// Front matter the listing can read the creation date back out of, a + /// blank line, then the title. #[test] - fn a_heading_is_the_trimmed_title_as_an_h1() { - assert_eq!(heading(" Migrate to AWS "), "# Migrate to AWS\n"); + fn a_new_note_is_front_matter_then_its_title() { + let text = new_note(" Keychain project spec ", "2024-11-22"); + assert_eq!( + text, + "---\ncreated: 2024-11-22\n---\n\n# Keychain project spec\n" + ); + let (block, body) = split_front_matter(&text); + let front = parse_front(block.unwrap(), utc()); + assert_eq!(date(front.created.unwrap(), utc()), "2024-11-22"); + assert_eq!(body.trim(), "# Keychain project spec"); } #[test] @@ -1771,8 +1821,10 @@ const MIN_BODY: usize = 24; /// links to, a note nothing has opened in a year — is a judgement about what /// the vault is *for*, and this is a list to look through rather than a verdict. /// -/// `scratch` is the one note that is never on it, whatever it holds. -pub fn junk(note: &Note, body: &str, scratch: &Path) -> Option { +/// `scratch` is the one note that is never on it, whatever it holds. The notes +/// under `daily`, a directory below the vault, are named for their day by +/// design, so a name without a letter in it is no reason to offer one. +pub fn junk(note: &Note, body: &str, scratch: &Path, daily: &str) -> Option { // Being empty is what the scratch note is *for* — it is emptied every time // it is used up — so offering to delete it would offer that on every run. if note.path == scratch { @@ -1786,7 +1838,10 @@ pub fn junk(note: &Note, body: &str, scratch: &Path) -> Option { return Some(Junk::Untitled); } // A front matter title is a name, wherever the file's own name came from. - if note.front.title.is_none() && !name.chars().any(char::is_alphabetic) { + if note.front.title.is_none() + && !name.chars().any(char::is_alphabetic) + && !under(¬e.rel, daily) + { return Some(Junk::Unnamed); } None diff --git a/tests/cli.rs b/tests/cli.rs index 07fd4ec..ab8b904 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -2244,28 +2244,74 @@ fn opened(run: &Run) -> PathBuf { PathBuf::from(run.stdout.trim().strip_prefix("-- ").expect("a path")) } +/// A note's text is `created:` front matter naming a day, a blank line, then +/// `# ` — returned as the day and what follows it. +fn dated(text: &str) -> (&str, &str) { + let rest = text.strip_prefix("---\ncreated: ").expect(text); + let (day, rest) = rest.split_at(10); + assert!( + day.chars().all(|c| c.is_ascii_digit() || c == '-'), + "{text}" + ); + (day, rest.strip_prefix("\n---\n\n").expect(text)) +} + #[test] -fn note_new_names_the_note_for_the_day_and_its_title_and_writes_the_title_as_its_h1() { +fn note_new_starts_a_dated_note_named_for_its_title_in_the_inbox() { let sandbox = Sandbox::new(); let vault = mk_vault(&sandbox); let run = sandbox.run(&["note", "new", "Migrate to AWS"]); run.ok(); let opened = opened(&run); - assert_eq!(opened.parent(), Some(vault.as_path()), "{opened:?}"); - let name = opened.file_name().unwrap().to_str().unwrap(); - let (day, rest) = name.split_at(10); - assert!( - day.chars().all(|c| c.is_ascii_digit() || c == '-'), - "{name}" - ); - assert_eq!(rest, "-migrate-to-aws.md"); + assert_eq!(opened, vault.join("inbox/migrate-to-aws.md")); + let text = std::fs::read_to_string(&opened).unwrap(); + assert_eq!(dated(&text).1, "# Migrate to AWS\n"); +} + +#[test] +fn note_new_and_note_daily_file_into_the_directories_the_config_names() { + let sandbox = Sandbox::new(); + let vault = mk_vault(&sandbox); + sandbox.write_config(&format!( + "[note]\nroot = {:?}\neditor = \"echo\"\ninbox = \"work/inbox\"\ndaily = \"personal/daily\"\n", + vault.display().to_string() + )); + + let new = sandbox.run(&["note", "new", "Standup"]); + new.ok(); + assert_eq!(opened(&new), vault.join("work/inbox/standup.md")); + + let daily = sandbox.run(&["note", "daily"]); + daily.ok(); assert_eq!( - std::fs::read_to_string(&opened).unwrap(), - "# Migrate to AWS\n" + opened(&daily).parent(), + Some(vault.join("personal/daily").as_path()) ); } +/// One note per day: the second run of the day reopens the first run's note +/// rather than starting another or writing over it. +#[test] +fn note_daily_starts_todays_note_once_and_reopens_it_after() { + let sandbox = Sandbox::new(); + let vault = mk_vault(&sandbox); + let first = sandbox.run(&["note", "daily"]); + first.ok(); + + let path = opened(&first); + let text = std::fs::read_to_string(&path).unwrap(); + let (day, body) = dated(&text); + assert_eq!(path, vault.join(format!("daily/{day}.md"))); + assert_eq!(body, format!("# Daily - {day}\n")); + + std::fs::write(&path, "written since\n").unwrap(); + let second = sandbox.run(&["note", "daily"]); + second.ok(); + assert_eq!(opened(&second), path); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "written since\n"); +} + /// One title typed twice is a second note, never the first one reopened. #[test] fn note_new_never_hands_back_a_name_already_in_use() {