Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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*
Expand Down
9 changes: 8 additions & 1 deletion src/binding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"),
Expand All @@ -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"),
];
Expand Down
22 changes: 21 additions & 1 deletion src/cmd/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -404,6 +414,15 @@ fn report(cfg: &Config, env: &Env) -> Vec<Row> {
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(
Expand Down Expand Up @@ -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());
Expand Down
97 changes: 71 additions & 26 deletions src/cmd/note.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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-<title-in-kebab-case>.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 `<title-in-kebab-case>.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(&note::titled_name(title, now, offset), |candidate| {
root.join(candidate).exists()
note::free_name(&note::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::<select::Cancelled>() {
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::<select::Cancelled>() {
true => e,
false => e.context("no TITLE given and no terminal to ask for one"),
})?,
};
let path = dir.join(file_for(&title));
create(&path, &note::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, &note::new_note(&note::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::<std::io::Error>()
.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.
Expand Down Expand Up @@ -485,7 +529,8 @@ fn junk(ctx: &Ctx, notes: &[Note]) -> Result<Vec<(Note, note::Junk, u64)>> {
let bytes = note.path.metadata().map(|m| m.len()).unwrap_or(0);
let text = read_head(&note.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);
Expand Down
16 changes: 16 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// Where `note new` starts a note, as a directory below [`Self::root`].
/// Unset, it is `inbox`.
pub inbox: Option<String>,
/// Where `note daily` keeps one note per day, as a directory below
/// [`Self::root`]. Unset, it is `daily`.
pub daily: Option<String>,
/// What `note open` launches, split on whitespace like `$EDITOR`. Unset, it
/// is `$VISUAL` then `$EDITOR`.
///
Expand All @@ -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> {
Expand Down
18 changes: 13 additions & 5 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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-<title-in-kebab-case>.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
/// `<title-in-kebab-case>.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<String>,
},
/// 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
Expand Down Expand Up @@ -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, &notes, 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),
},
Expand Down
Loading