diff --git a/README.md b/README.md index 256fc19..bfcf6f9 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,8 @@ it; in the document pane it is that document. In the browse pane: | `b` | the board of this space | | `t` | the agenda: what is late, due this week, or in hand, on every board | | `T` | today's diary entry, started if there is none | +| `C` | the diary by month (`:cal`) — see [The diary](#the-diary) | +| `[d` / `]d` | the diary entry before / after the one open, by date | | `c` | capture a card into this space's inbox without leaving where you are | | `Esc`, `q` | close the document, and its tile when there are several | | `h` | up one level in the tree | @@ -113,7 +115,7 @@ it; in the document pane it is that document. In the browse pane: Inside the editor the full vim keymap is live — `w`/`b`, `dd`, `ciw`, visual mode, macros, `:w` to write. `:help` lists every ex command; the useful ones are -`:w`, `:wq`, `:q!`, `:new [yyyy-mm-dd]`, `:date`, `:name`, `:share`, `:link`, +`:w`, `:wq`, `:q!`, `:new [template] [yyyy-mm-dd] [name]`, `:date`, `:name`, `:share`, `:link`, `:upload`, `:media`, `:search`, `:backup` and `:set novim`. `:name` on its own moves up into the title; `Enter`, `Esc` or `↓` there goes back down into the text. In the text, `]]`, `[[` and `gO` walk the @@ -241,6 +243,32 @@ it stands, `D` sets its due date (`:due` on the line), `H` / `L` send it to the list left / right on its board, and `T` writes today's entry. The bar shows `late` and `today` counts whenever either is above zero, and the agenda opens by itself once a day when something is late or due, so nothing has to be remembered to be looked at. +Below the cards it lists what the diary holds from this day in earlier years; +`j` walks on into them and `Enter` opens one. A 29 February is remembered on +the 28th in the years that have none. + +## The diary + +The space with the diary role is a journal: each document in it is an entry, +dated by the day it is about (`:date` changes that). `T` opens today's, `[d` +and `]d` step to the one before or after by date, and `C` (or `:cal`) shows a +month: `h` / `l` move a day, `j` / `k` a week, `H` / `L` a month, `.` goes +back to today, and `Enter` opens the day's entry or starts one dated that day. +A marked day has an entry, a number says how many. Days are counted in +`WORKSPACE_TIMEZONE`, the same as the evening reminder. + +### Templates + +Any space can have a folder called `templates` at its root; every document in +it is a template. `:new meeting` starts a document from `templates/meeting`, +`:new meeting 2026-10-20 Budget` dates and names it as well, and Tab completes +the template names. In a template, `{{date}}` becomes `2026-10-20`, +`{{weekday}}` `Tuesday`, `{{time}}` `14:05` and `{{title}}` the new document's +name, all on the clock of `WORKSPACE_TIMEZONE` at the date the document is +for. A new entry in the diary — from `T`, `o`, the calendar or a capture — +starts from `templates/day` when the diary has one. The templates are not +entries, so they are not on the calendar and do not count as having written +today. ## Capturing from elsewhere @@ -264,7 +292,7 @@ named by `"space": ""`, or else of the first space that has a board, the same as `:card` outside the board. The first line is the card's title and the rest its notes. `"to": "diary"` adds `- 14:05 call the bank` to the end of today's entry, in `WORKSPACE_TIMEZONE`, and starts the entry when there is -none yet. The answer says where it went: `{"to":"card","id":7,"where":"work/backlog"}`. +none yet, from `templates/day` when the diary has one. The answer says where it went: `{"to":"card","id":7,"where":"work/backlog"}`. A token opens this one route and nothing else — it reads nothing, not even the list of spaces — and only its SHA-256 is kept, the way a share key is. Wrong @@ -736,13 +764,16 @@ sealed or not. | `GET` | `/api/sessions` | every signed-in browser, the one seen last first | | `DELETE` | `/api/sessions/{id}` | sign another browser out, and lock the vault | | `GET`/`POST` | `/api/spaces` | the spaces / create one | -| `GET`/`POST` | `/api/nodes` | children (`?parent=`) or search (`?q=`, `?space=`) / create | +| `GET`/`POST` | `/api/nodes` | children (`?parent=`) or search (`?q=`, `?space=`) / create, from a template with `template_id` | | `GET`/`PUT`/`DELETE` | `/api/nodes/{id}` | read / update / delete into the trash, subtree and all — or for good, for something already in it | | `GET`/`DELETE` | `/api/trash` | what was deleted, the last first / empty it | | `DELETE` | `/api/trash/{id}` | delete one thing in the trash for good | | `POST` | `/api/trash/{id}/restore` | back where it was deleted from, or to its space when that folder is gone | | `GET` | `/api/spaces/{id}/board` | the lists and cards of one space | -| `GET` | `/api/agenda` | open cards due within a week or in hand, on every board, and whether the diary has today | +| `GET` | `/api/agenda` | open cards due within a week or in hand, on every board, whether the diary has today, and its entries `on_this_day` in earlier years | +| `GET` | `/api/diary/days?month=2026-10` | the diary's entries in a month, each with its local `day` | +| `GET` | `/api/diary/around/{id}` | the entries before and after a diary entry, by date | +| `GET` | `/api/nodes/{id}/templates` | the templates of the space the node is in | | `POST` | `/api/capture` | `{ text, to: "card" \| "diary", space? }`, with a session or `Authorization: Bearer` a token | | `GET`/`POST` | `/api/tokens` | the capture tokens / make one, `{ name }`, answered with the token this once | | `DELETE` | `/api/tokens/{id}` | remove a capture token | diff --git a/src/notify/mod.rs b/src/notify/mod.rs index 48da8e3..0fb509c 100644 --- a/src/notify/mod.rs +++ b/src/notify/mod.rs @@ -461,16 +461,14 @@ fn device_hash(user_agent: &str) -> String { /// whether today has been written. pub(crate) async fn diary_written(db: &SqlitePool, tz: Tz, day: NaiveDate) -> sqlx::Result { let (start, end) = day_bounds(tz, day); - let written: i64 = sqlx::query_scalar( - "WITH RECURSIVE tree (id) AS ( - SELECT id FROM nodes WHERE role = 'diary' - UNION ALL - SELECT n.id FROM nodes n JOIN tree t ON n.parent_id = t.id - ) + // A template is not an entry, however recently it was written. + let written: i64 = sqlx::query_scalar(&format!( + "WITH RECURSIVE {} SELECT count(*) FROM nodes - WHERE kind = 'document' AND id IN (SELECT id FROM tree) + WHERE kind = 'document' AND id IN (SELECT id FROM entries) AND created_at >= ? AND created_at < ?", - ) + crate::routes::diary::ENTRIES + )) .bind(start) .bind(end) .fetch_one(db) diff --git a/src/routes/boards.rs b/src/routes/boards.rs index 010a5ce..a4f2dd8 100644 --- a/src/routes/boards.rs +++ b/src/routes/boards.rs @@ -140,6 +140,8 @@ pub struct Agenda { /// Whether the diary has an entry for today, in the configured timezone — /// `None` when there is no diary space to ask about. pub diary_today: Option, + /// Diary entries from this day in earlier years, the latest year first. + pub on_this_day: Vec, } #[derive(Serialize)] @@ -162,16 +164,21 @@ pub async fn agenda(_: Session, State(state): State) -> AppResult = sqlx::query_scalar("SELECT id FROM nodes WHERE role = 'diary'") .fetch_optional(&state.db) .await?; + let tz = state.config.mail.timezone; let diary_today = match has_diary { Some(_) => { - let tz = state.config.mail.timezone; let today = crate::notify::local_day(tz, crate::now()); Some(crate::notify::diary_written(&state.db, tz, today).await?) } None => None, }; + let on_this_day = crate::routes::diary::on_this_day(&state.db, tz, crate::now()).await?; - Ok(Json(Agenda { cards, diary_today })) + Ok(Json(Agenda { + cards, + diary_today, + on_this_day, + })) } /// The agenda's cards, due before `horizon` or in hand. Apart from the handler diff --git a/src/routes/capture.rs b/src/routes/capture.rs index b24a534..a10fa82 100644 --- a/src/routes/capture.rs +++ b/src/routes/capture.rs @@ -14,7 +14,7 @@ use crate::{ error::{AppError, AppResult}, routes::{ boards::{self, CardInput}, - links, media, + diary, links, media, nodes::{self, slugify, unique_slug}, revisions, }, @@ -96,7 +96,7 @@ async fn card(db: &SqlitePool, text: &str, space: Option<&str>) -> AppResult AppResult { let line = format!("- {} {}", clock(tz, now), item(text)); @@ -134,7 +134,8 @@ async fn diary(db: &SqlitePool, tz: Tz, text: &str, now: i64) -> AppResult { - let body = format!("{line}\n"); + let start = diary::day_template(&mut tx, tz, diary, now).await?; + let body = appended(start.as_deref().unwrap_or_default(), &line); let slug = unique_slug(&mut tx, Some(diary), &slugify(""), None).await?; let id: i64 = sqlx::query_scalar( "INSERT INTO nodes (parent_id, kind, name, slug, body, created_at, updated_at) @@ -203,7 +204,7 @@ fn appended(body: &str, line: &str) -> String { } #[cfg(test)] -mod tests { +pub(crate) mod tests { use axum::{ body::Body, http::{header, Request, StatusCode}, @@ -218,6 +219,12 @@ mod tests { const TOKEN: &str = "nwc_a-token-for-the-tests"; + /// A line in the diary in Berlin at `now`, for tests elsewhere: the id of + /// the entry it went into. + pub(crate) async fn diary_at(db: &sqlx::SqlitePool, text: &str, now: i64) -> i64 { + diary(db, Berlin, text, now).await.unwrap().id + } + async fn state() -> AppState { let state = crate::routes::tests::state_in(".".into()).await; sqlx::query("INSERT INTO api_tokens (name, hash, created_at) VALUES ('phone', ?1, 0)") diff --git a/src/routes/diary.rs b/src/routes/diary.rs new file mode 100644 index 0000000..4a37bf6 --- /dev/null +++ b/src/routes/diary.rs @@ -0,0 +1,541 @@ +//! The diary as a diary rather than a list: the days that have an entry, the +//! entry before and after the one being read, what was written on this day in +//! earlier years — and templates, which every space has, but which the diary +//! is the first to want. +//! +//! A day is a local day in `WORKSPACE_TIMEZONE`, the same one the evening +//! reminder asks about, so the calendar and the mail never disagree. + +use axum::{ + extract::{Path, Query, State}, + Json, +}; +use chrono::{Datelike, Months, NaiveDate, TimeZone, Utc}; +use chrono_tz::Tz; +use serde::{Deserialize, Serialize}; +use sqlx::{Row, SqliteConnection, SqlitePool}; + +use crate::{ + auth::Session, + error::{AppError, AppResult}, + notify::{day_bounds, local_day, weekday_name}, + routes::nodes::{excerpt, path_of, HEAD_CHARS}, + state::AppState, +}; + +/// The folder at the root of a space that holds its templates. Found by name, +/// as a person would look for it: `role` is for the one diary and the trash. +pub const TEMPLATES: &str = "templates"; + +/// The template a new diary entry starts from, when the diary has one. +pub const DAY: &str = "day"; + +/// The diary's entries: every document in the diary space except the +/// templates in its `templates` folder, which are about no day at all. +/// `WITH RECURSIVE {ENTRIES} SELECT … WHERE kind = 'document' AND id IN +/// (SELECT id FROM entries)`. +pub(crate) const ENTRIES: &str = "entries (id) AS ( + SELECT id FROM nodes WHERE role = 'diary' + UNION ALL + SELECT n.id FROM nodes n JOIN entries e ON n.parent_id = e.id + WHERE NOT (n.kind = 'folder' AND n.slug = 'templates' + AND n.parent_id = (SELECT id FROM nodes WHERE role = 'diary')) + )"; + +#[derive(Serialize)] +pub struct Template { + pub id: i64, + pub name: String, + pub slug: String, +} + +/// `{{date}}`, `{{weekday}}`, `{{time}}` and `{{title}}` in a template, for a +/// document dated `at`. The title goes in last, so a title that happens to +/// contain a placeholder is left as it was typed. +pub(crate) fn fill(text: &str, tz: Tz, at: i64, title: &str) -> String { + let local = Utc + .timestamp_opt(at, 0) + .single() + .unwrap_or_else(Utc::now) + .with_timezone(&tz); + text.replace("{{date}}", &local.format("%Y-%m-%d").to_string()) + .replace("{{weekday}}", weekday_name(local.weekday())) + .replace("{{time}}", &local.format("%H:%M").to_string()) + .replace("{{title}}", title) +} + +/// The templates of the space `space_id`, by name. +pub(crate) async fn templates_of( + conn: &mut SqliteConnection, + space_id: i64, +) -> AppResult> { + let rows = sqlx::query( + "SELECT t.id, t.name, t.slug FROM nodes t + JOIN nodes f ON f.id = t.parent_id + WHERE f.parent_id = ?1 AND f.kind = 'folder' AND f.slug = ?2 AND t.kind = 'document' + ORDER BY t.slug", + ) + .bind(space_id) + .bind(TEMPLATES) + .fetch_all(&mut *conn) + .await?; + Ok(rows + .iter() + .map(|row| Template { + id: row.get("id"), + name: row.get("name"), + slug: row.get("slug"), + }) + .collect()) +} + +/// The body of the template `id`, filled in for a document called `title` +/// dated `at`. +pub(crate) async fn from_template( + conn: &mut SqliteConnection, + tz: Tz, + id: i64, + at: i64, + title: &str, +) -> AppResult { + let body: Option = + sqlx::query_scalar("SELECT body FROM nodes WHERE id = ?1 AND kind = 'document'") + .bind(id) + .fetch_optional(&mut *conn) + .await?; + let body = body.ok_or_else(|| AppError::BadRequest("a template is a document".into()))?; + Ok(fill(&body, tz, at, title)) +} + +/// The diary's `templates/day`, filled in for `at`, when there is one. +pub(crate) async fn day_template( + conn: &mut SqliteConnection, + tz: Tz, + diary: i64, + at: i64, +) -> AppResult> { + let day = templates_of(conn, diary) + .await? + .into_iter() + .find(|template| template.slug == DAY); + match day { + Some(template) => Ok(Some(from_template(conn, tz, template.id, at, "").await?)), + None => Ok(None), + } +} + +/// `GET /api/nodes/{id}/templates`: the templates of the space `id` is in. +pub async fn templates( + _: Session, + State(state): State, + Path(id): Path, +) -> AppResult>> { + let path = path_of(&state.db, id).await?; + let space = path.first().ok_or(AppError::NotFound)?; + let mut conn = state.db.acquire().await?; + templates_of(&mut conn, space.id).await.map(Json) +} + +#[derive(Deserialize)] +pub struct DaysQuery { + /// `2026-10`. + pub month: String, +} + +/// An entry in the diary, as the calendar and the agenda show it. +#[derive(Serialize, Debug)] +pub struct Entry { + pub id: i64, + pub name: String, + /// The local day it is about: `2026-10-20`. + pub day: String, + pub created_at: i64, + /// `diary/untitled`, to open it by. + pub path: String, + pub excerpt: String, +} + +async fn entries_between(db: &SqlitePool, tz: Tz, start: i64, end: i64) -> AppResult> { + let rows = sqlx::query(&format!( + "WITH RECURSIVE {ENTRIES} + SELECT id, name, created_at, substr(body, 1, {HEAD_CHARS}) AS head FROM nodes + WHERE kind = 'document' AND id IN (SELECT id FROM entries) + AND created_at >= ?1 AND created_at < ?2 + ORDER BY created_at, id" + )) + .bind(start) + .bind(end) + .fetch_all(db) + .await?; + let mut entries = Vec::with_capacity(rows.len()); + for row in rows { + let id: i64 = row.get("id"); + let created_at: i64 = row.get("created_at"); + entries.push(Entry { + id, + name: row.get("name"), + day: local_day(tz, created_at).format("%Y-%m-%d").to_string(), + created_at, + path: slug_path(db, id).await?, + excerpt: excerpt(row.get("head")), + }); + } + Ok(entries) +} + +async fn slug_path(db: &SqlitePool, id: i64) -> AppResult { + Ok(path_of(db, id) + .await? + .iter() + .map(|crumb| crumb.slug.as_str()) + .collect::>() + .join("/")) +} + +/// The entries of one month, from the first of it to the first of the next, +/// both in the configured timezone. +async fn month_entries(db: &SqlitePool, tz: Tz, month: &str) -> AppResult> { + let first = NaiveDate::parse_from_str(&format!("{month}-01"), "%Y-%m-%d") + .map_err(|_| AppError::BadRequest(format!("not a month: {month}, but 2026-10 is")))?; + let next = first + .checked_add_months(Months::new(1)) + .ok_or_else(|| AppError::BadRequest(format!("not a month: {month}")))?; + entries_between(db, tz, day_bounds(tz, first).0, day_bounds(tz, next).0).await +} + +/// `GET /api/diary/days?month=2026-10`: the entries of a month, for the +/// calendar to mark its days with. +pub async fn days( + _: Session, + State(state): State, + Query(query): Query, +) -> AppResult>> { + month_entries(&state.db, state.config.mail.timezone, &query.month) + .await + .map(Json) +} + +#[derive(Serialize, Debug, Default)] +pub struct Around { + pub prev: Option, + pub next: Option, +} + +/// The entries either side of `id` by date: `[d` and `]d`. +async fn around(db: &SqlitePool, tz: Tz, id: i64) -> AppResult { + let at: Option = sqlx::query_scalar(&format!( + "WITH RECURSIVE {ENTRIES} + SELECT created_at FROM nodes + WHERE id = ?1 AND kind = 'document' AND id IN (SELECT id FROM entries)" + )) + .bind(id) + .fetch_optional(db) + .await?; + let at = at.ok_or_else(|| AppError::BadRequest("this is not a diary entry".into()))?; + + let step = |before: bool| { + let (compare, order) = if before { ("<", "DESC") } else { (">", "ASC") }; + format!( + "WITH RECURSIVE {ENTRIES} + SELECT id FROM nodes + WHERE kind = 'document' AND id IN (SELECT id FROM entries) + AND (created_at {compare} ?1 OR (created_at = ?1 AND id {compare} ?2)) + ORDER BY created_at {order}, id {order} + LIMIT 1" + ) + }; + let mut found = Around::default(); + for before in [true, false] { + let next: Option = sqlx::query_scalar(&step(before)) + .bind(at) + .bind(id) + .fetch_optional(db) + .await?; + let Some(next) = next else { continue }; + let created_at: i64 = sqlx::query_scalar("SELECT created_at FROM nodes WHERE id = ?1") + .bind(next) + .fetch_one(db) + .await?; + let entry = entries_between(db, tz, created_at, created_at + 1) + .await? + .into_iter() + .find(|entry| entry.id == next); + if before { + found.prev = entry; + } else { + found.next = entry; + } + } + Ok(found) +} + +/// `GET /api/diary/around/{id}`. +pub async fn around_entry( + _: Session, + State(state): State, + Path(id): Path, +) -> AppResult> { + around(&state.db, state.config.mail.timezone, id) + .await + .map(Json) +} + +/// What was written on this day in earlier years, the latest year first. +/// 29 February comes round only every fourth year; in the others it is +/// remembered on the 28th, so a leap day is not skipped for three years. +pub(crate) async fn on_this_day(db: &SqlitePool, tz: Tz, now: i64) -> AppResult> { + let first: Option = sqlx::query_scalar(&format!( + "WITH RECURSIVE {ENTRIES} + SELECT MIN(created_at) FROM nodes + WHERE kind = 'document' AND id IN (SELECT id FROM entries)" + )) + .fetch_one(db) + .await?; + let Some(first) = first else { + return Ok(Vec::new()); + }; + + let today = local_day(tz, now); + let leap = NaiveDate::from_ymd_opt(today.year(), 2, 29).is_some(); + let late_leap_day = !leap && today.month() == 2 && today.day() == 28; + let mut found = Vec::new(); + for year in (local_day(tz, first).year()..today.year()).rev() { + let mut days = vec![NaiveDate::from_ymd_opt(year, today.month(), today.day())]; + if late_leap_day { + days.push(NaiveDate::from_ymd_opt(year, 2, 29)); + } + for day in days.into_iter().flatten() { + let (start, end) = day_bounds(tz, day); + found.extend(entries_between(db, tz, start, end).await?); + } + } + Ok(found) +} + +/// Whether `id` is the diary space itself, where a new document is an entry. +pub(crate) async fn is_diary(conn: &mut SqliteConnection, id: i64) -> AppResult { + let role: Option> = sqlx::query_scalar("SELECT role FROM nodes WHERE id = ?1") + .bind(id) + .fetch_optional(&mut *conn) + .await?; + Ok(role.flatten().as_deref() == Some(crate::routes::nodes::DIARY)) +} + +#[cfg(test)] +mod tests { + use axum::{extract::State, Json}; + use chrono::TimeZone; + use chrono_tz::Europe::Berlin; + use sqlx::SqlitePool; + + use super::{around, fill, month_entries, on_this_day}; + use crate::{auth::Session, routes::nodes::CreateInput, state::AppState}; + + fn at(y: i32, mo: u32, d: u32, h: u32, mi: u32) -> i64 { + Berlin + .with_ymd_and_hms(y, mo, d, h, mi, 0) + .earliest() + .unwrap() + .timestamp() + } + + async fn diary(db: &SqlitePool) -> i64 { + sqlx::query_scalar("SELECT id FROM nodes WHERE role = 'diary'") + .fetch_one(db) + .await + .unwrap() + } + + async fn add(db: &SqlitePool, parent: i64, kind: &str, name: &str, body: &str, at: i64) -> i64 { + let slug = crate::routes::nodes::slugify(name); + let slug = if slug == "untitled" { + format!("e{at}") + } else { + slug + }; + sqlx::query_scalar( + "INSERT INTO nodes (parent_id, kind, name, slug, body, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6) RETURNING id", + ) + .bind(parent) + .bind(kind) + .bind(name) + .bind(slug) + .bind(body) + .bind(at) + .fetch_one(db) + .await + .unwrap() + } + + async fn create(state: &AppState, input: CreateInput) -> String { + let Json(node) = crate::routes::nodes::create( + Session { id: "test".into() }, + State(state.clone()), + Json(input), + ) + .await + .unwrap(); + node.body + } + + fn input(parent_id: i64, name: &str, created_at: i64, template_id: Option) -> CreateInput { + CreateInput { + parent_id, + kind: None, + name: name.into(), + body: String::new(), + created_at: Some(created_at), + template_id, + } + } + + #[test] + fn placeholders_keep_to_the_wall_clock_across_daylight_saving() { + let text = "{{weekday}} {{date}} {{time}} {{title}}"; + // Spring: 01:59 is followed by 03:00. + assert_eq!( + fill(text, Berlin, at(2026, 3, 29, 1, 30), "a"), + "Sunday 2026-03-29 01:30 a" + ); + assert_eq!( + fill(text, Berlin, at(2026, 3, 29, 1, 30) + 3600, "a"), + "Sunday 2026-03-29 03:30 a" + ); + // Autumn: 02:30 happens twice, an hour apart. + let first = at(2026, 10, 25, 2, 30); + assert_eq!(fill("{{time}}", Berlin, first, ""), "02:30"); + assert_eq!(fill("{{time}}", Berlin, first + 3600, ""), "02:30"); + // Before midnight in UTC, after it here. + assert_eq!( + fill(text, Berlin, at(2026, 10, 21, 0, 30), "{{date}}"), + "Wednesday 2026-10-21 00:30 {{date}}" + ); + } + + #[tokio::test] + async fn a_new_document_starts_from_its_template_and_an_entry_from_the_day() { + let state = crate::routes::tests::state_in(".".into()).await; + let db = &state.db; + let diary = diary(db).await; + let work: i64 = sqlx::query_scalar("SELECT id FROM nodes WHERE slug = 'work'") + .fetch_one(db) + .await + .unwrap(); + let morning = at(2026, 10, 20, 8, 15); + + // No day template yet: an entry starts empty, as it always has. + assert_eq!(create(&state, input(diary, "", morning, None)).await, ""); + + let folder = add(db, work, "folder", "templates", "", 0).await; + let meeting = add( + db, + folder, + "document", + "meeting", + "# {{title}}\n{{weekday}} {{date}}\n", + 0, + ) + .await; + let body = create(&state, input(work, "Budget", morning, Some(meeting))).await; + assert_eq!(body, "# Budget\nTuesday 2026-10-20\n"); + + let folder = add(db, diary, "folder", "templates", "", morning).await; + add(db, folder, "document", "day", "## {{weekday}}\n", morning).await; + let body = create(&state, input(diary, "", at(2026, 10, 21, 9, 0), None)).await; + assert_eq!(body, "## Wednesday\n"); + // The template is not an entry of the day it was written on. + let days = month_entries(db, Berlin, "2026-10").await.unwrap(); + assert_eq!(days.len(), 2, "{days:?}"); + + // Capture that starts a day starts it from the template too. + let captured = + crate::routes::capture::tests::diary_at(db, "rain", at(2026, 10, 22, 7, 0)).await; + let body: String = sqlx::query_scalar("SELECT body FROM nodes WHERE id = ?1") + .bind(captured) + .fetch_one(db) + .await + .unwrap(); + assert_eq!(body, "## Thursday\n\n- 07:00 rain\n"); + } + + #[tokio::test] + async fn a_month_holds_the_days_of_the_timezone() { + let state = crate::routes::tests::state_in(".".into()).await; + let db = &state.db; + let diary = diary(db).await; + // An hour apart in UTC, on 31 October both, but a month apart here. + let october = add(db, diary, "document", "", "", at(2026, 10, 31, 23, 30)).await; + let november = add(db, diary, "document", "", "", at(2026, 11, 1, 0, 30)).await; + + let days = month_entries(db, Berlin, "2026-10").await.unwrap(); + assert_eq!(days.iter().map(|e| e.id).collect::>(), [october]); + assert_eq!(days[0].day, "2026-10-31"); + let days = month_entries(db, Berlin, "2026-11").await.unwrap(); + assert_eq!(days.iter().map(|e| e.id).collect::>(), [november]); + assert_eq!(days[0].day, "2026-11-01"); + assert!(days[0].path.starts_with("diary/"), "{}", days[0].path); + // December rolls over into the next year. + assert!(month_entries(db, Berlin, "2026-12") + .await + .unwrap() + .is_empty()); + for month in ["2026-13", "october", ""] { + assert!(month_entries(db, Berlin, month).await.is_err(), "{month}"); + } + } + + #[tokio::test] + async fn the_entries_either_side_go_by_date() { + let state = crate::routes::tests::state_in(".".into()).await; + let db = &state.db; + let diary = diary(db).await; + let folder = add(db, diary, "folder", "2025", "", 0).await; + let older = add(db, folder, "document", "", "", at(2025, 12, 31, 12, 0)).await; + let middle = add(db, diary, "document", "", "", at(2026, 1, 2, 12, 0)).await; + // Written later, about an earlier day. + let backdated = add(db, diary, "document", "", "", at(2026, 1, 1, 12, 0)).await; + + let found = around(db, Berlin, backdated).await.unwrap(); + assert_eq!(found.prev.map(|e| e.id), Some(older)); + assert_eq!(found.next.map(|e| e.id), Some(middle)); + let found = around(db, Berlin, older).await.unwrap(); + assert!(found.prev.is_none()); + assert!(around(db, Berlin, middle).await.unwrap().next.is_none()); + assert!(around(db, Berlin, folder).await.is_err()); + } + + #[tokio::test] + async fn on_this_day_remembers_the_leap_day_in_the_years_without_one() { + let state = crate::routes::tests::state_in(".".into()).await; + let db = &state.db; + let diary = diary(db).await; + let leap = add(db, diary, "document", "", "", at(2024, 2, 29, 20, 0)).await; + let plain = add(db, diary, "document", "", "", at(2025, 2, 28, 20, 0)).await; + add(db, diary, "document", "", "", at(2025, 3, 1, 20, 0)).await; + let today = add(db, diary, "document", "", "", at(2027, 2, 28, 8, 0)).await; + + let ids = |found: Vec| found.into_iter().map(|e| e.id).collect::>(); + // 2027 has no 29th, so its 28th remembers both, the latest year first; + // its own entry is today's, not a memory. + assert_eq!( + ids(on_this_day(db, Berlin, at(2027, 2, 28, 9, 0)) + .await + .unwrap()), + [plain, leap] + ); + // A leap year has a 29th of its own to remember the leap day on. + assert_eq!( + ids(on_this_day(db, Berlin, at(2028, 2, 28, 9, 0)) + .await + .unwrap()), + [today, plain] + ); + assert_eq!( + ids(on_this_day(db, Berlin, at(2028, 2, 29, 9, 0)) + .await + .unwrap()), + [leap] + ); + } +} diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 9a430ef..fb3cb17 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -1,6 +1,7 @@ pub mod backup; pub mod boards; pub mod capture; +pub mod diary; pub mod export; pub mod import; pub mod inspect; @@ -56,6 +57,8 @@ pub fn api_router(max_upload: usize) -> Router { .route("/spaces", get(nodes::spaces).post(nodes::create_space)) .route("/spaces/overview", get(nodes::overview)) .route("/agenda", get(boards::agenda)) + .route("/diary/days", get(diary::days)) + .route("/diary/around/{id}", get(diary::around_entry)) .route("/capture", post(capture::capture)) .route("/spaces/{id}/board", get(boards::board)) .route("/spaces/{id}/lists", post(boards::create_list)) @@ -76,6 +79,7 @@ pub fn api_router(max_upload: usize) -> Router { .route("/nodes/{id}", delete(nodes::remove)) .route("/nodes/{id}/revisions", get(revisions::list)) .route("/nodes/{id}/backlinks", get(links::backlinks)) + .route("/nodes/{id}/templates", get(diary::templates)) .route("/nodes/{id}/revisions/{rev}", get(revisions::get)) .route("/nodes/move", post(nodes::move_many)) .route("/nodes/{id}/move", post(nodes::move_node)) diff --git a/src/routes/nodes.rs b/src/routes/nodes.rs index 7b7482d..5c25174 100644 --- a/src/routes/nodes.rs +++ b/src/routes/nodes.rs @@ -10,7 +10,7 @@ use sqlx::{Row, SqliteConnection, SqlitePool}; use crate::{ auth::Session, error::{AppError, AppResult}, - routes::{links, media, revisions, trash}, + routes::{diary, links, media, revisions, trash}, state::AppState, }; @@ -148,6 +148,10 @@ pub struct CreateInput { /// Optional override so a document can be backdated to the day it is about. #[serde(default)] pub created_at: Option, + /// A document to start from, its placeholders filled in for this one. A + /// new entry in the diary starts from `templates/day` without being asked. + #[serde(default)] + pub template_id: Option, } #[derive(Deserialize)] @@ -208,7 +212,7 @@ const MOVE_AT_ONCE: usize = 1000; /// How much of a body the list query reads. Comfortably more than an excerpt /// needs, and a rounding error next to documents that run to thousands of words. -const HEAD_CHARS: usize = 1024; +pub(crate) const HEAD_CHARS: usize = 1024; const EXCERPT_CHARS: usize = 180; /// The columns every list query selects, so a row always converts the same way. @@ -223,7 +227,7 @@ const SUMMARY_COLUMNS: &str = /// A short, plain-ish preview of the body for the list pane. `head` is only the /// leading `HEAD_CHARS` of the document, so truncation is inferred from its /// length rather than measured against the whole body. -fn excerpt(head: &str) -> String { +pub(crate) fn excerpt(head: &str) -> String { let flat: String = head .lines() .map(str::trim) @@ -626,6 +630,31 @@ pub async fn create( let created_at = input.created_at.unwrap_or(now); let slug = unique_slug(&mut tx, Some(input.parent_id), &slugify(&input.name), None).await?; + let tz = state.config.mail.timezone; + let name = input.name.trim(); + let body = match input.template_id { + Some(_) if kind != DOCUMENT => { + return Err(AppError::BadRequest( + "only a document starts from a template".into(), + )); + } + Some(_) if !input.body.is_empty() => { + return Err(AppError::BadRequest( + "a document starts from a template or from a body, not both".into(), + )); + } + Some(template) => diary::from_template(&mut tx, tz, template, created_at, name).await?, + None if kind == DOCUMENT + && input.body.is_empty() + && diary::is_diary(&mut tx, input.parent_id).await? => + { + diary::day_template(&mut tx, tz, input.parent_id, created_at) + .await? + .unwrap_or_default() + } + None => input.body, + }; + let id: i64 = sqlx::query( "INSERT INTO nodes (parent_id, kind, name, slug, body, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) @@ -633,17 +662,17 @@ pub async fn create( ) .bind(input.parent_id) .bind(kind) - .bind(input.name.trim()) + .bind(name) .bind(&slug) - .bind(&input.body) + .bind(&body) .bind(created_at) .bind(now) .fetch_one(&mut *tx) .await? .get("id"); - media::link_to_node(&mut tx, id, &input.body).await?; - links::index(&mut tx, id, &input.body).await?; + media::link_to_node(&mut tx, id, &body).await?; + links::index(&mut tx, id, &body).await?; // A `[[link]]` written before the document it names now finds it. links::follow(&mut tx, id).await?; tx.commit().await?; diff --git a/web/src/App.svelte b/web/src/App.svelte index 0f800d2..9857e9b 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -2,6 +2,7 @@ import { cycleTile, focusTile, followLink, hooks, runCommand } from './lib/commands' import { embedSnippet } from './lib/markdown' import Agenda from './lib/Agenda.svelte' + import Calendar from './lib/Calendar.svelte' import Bar from './lib/Bar.svelte' import Board from './lib/Board.svelte' import CommandLine from './lib/CommandLine.svelte' @@ -25,7 +26,7 @@ import Sidebar from './lib/Sidebar.svelte' import StatusBar from './lib/StatusBar.svelte' import { workspace } from './lib/store.svelte' - import { dwindle, neighbour, type Direction } from './lib/util' + import { dwindle, formatDay, neighbour, type Direction } from './lib/util' /* * A share link is /s/#. The fragment never leaves the browser, so @@ -292,14 +293,18 @@ return false } - /** The agenda's keys: j/k down the list, Enter to the card, Space finishes it. */ + /** + * The agenda's keys: j/k down the list, Enter to the card, Space finishes it. + * Past the cards the cursor walks on into what was written on this day. + */ function agendaKey(event: KeyboardEvent): boolean { const cards = workspace.agendaCards const card = cards[workspace.agendaCursor] + const past = workspace.agenda.on_this_day[workspace.agendaCursor - cards.length] switch (event.key) { case 'j': case 'ArrowDown': - workspace.agendaCursor = Math.min(workspace.agendaCursor + 1, Math.max(cards.length - 1, 0)) + workspace.agendaCursor = Math.min(workspace.agendaCursor + 1, Math.max(cards.length + workspace.agenda.on_this_day.length - 1, 0)) return true case 'k': case 'ArrowUp': @@ -307,6 +312,10 @@ return true case 'Enter': if (card) void workspace.guard(() => workspace.goToCard(card)) + else if (past) { + workspace.overlay = 'none' + void workspace.guard(() => workspace.openPath(past.path)) + } return true case ' ': if (card) void workspace.guard(() => workspace.finishCard(card)) @@ -335,6 +344,35 @@ } } + /** The calendar's keys: h/l a day, j/k a week, H/L a month, Enter to the day. */ + function calendarKey(event: KeyboardEvent): boolean { + const step: Record = { + h: [-1, 0], ArrowLeft: [-1, 0], l: [1, 0], ArrowRight: [1, 0], + k: [-7, 0], ArrowUp: [-7, 0], j: [7, 0], ArrowDown: [7, 0], + H: [0, -1], L: [0, 1], + } + if (event.key in step) { + const [days, months] = step[event.key] + void workspace.guard(() => workspace.moveCalendar(days, months)) + return true + } + switch (event.key) { + case '.': + workspace.calendarDay = formatDay(Math.floor(Date.now() / 1000)) + void workspace.guard(() => workspace.loadCalendar()) + return true + case 'Enter': + void workspace.guard(() => workspace.openCalendarDay()).then(() => hooks.focusEditor()) + return true + case 'C': + case 'q': + workspace.overlay = 'none' + return true + default: + return false + } + } + /** `gO`'s list: j/k down the headings, Enter to the one picked. */ function outlineKey(event: KeyboardEvent): boolean { const last = Math.max(workspace.outline.length - 1, 0) @@ -675,6 +713,12 @@ return } } + if (workspace.overlay === 'calendar' && listening && !event.ctrlKey && !event.metaKey && !event.altKey) { + if (calendarKey(event)) { + event.preventDefault() + return + } + } if (workspace.overlay === 'outline' && listening && !event.ctrlKey && !event.metaKey && !event.altKey) { if (outlineKey(event)) { event.preventDefault() @@ -769,6 +813,11 @@ void runCommand('today') return } + if (key === 'C') { + event.preventDefault() + void runCommand('cal') + return + } if (workspace.view === 'board') { if (previous === 'g' && key === 'g') { @@ -847,6 +896,12 @@ if (!reading || !hooks.scrollWide(amount)) workspace.say('nothing wider than the column in view', 'error') return } + // `[d` / `]d`: the diary entry of the day before or after, by its date. + if ((previous === '[' || previous === ']') && key === 'd') { + event.preventDefault() + void workspace.guard(() => workspace.stepEntry(previous === '[' ? -1 : 1)) + return + } if ((previous === ']' && key === ']') || (previous === '[' && key === '[')) { event.preventDefault() // Past the last heading there is still the end, as in vim. @@ -1112,7 +1167,7 @@
e.target === e.currentTarget && (workspace.overlay = 'none')} > - {#if workspace.overlay === 'help'}{:else if workspace.overlay === 'overview'}{:else if workspace.overlay === 'agenda'}{:else if workspace.overlay === 'actions'}{:else if workspace.overlay === 'verify'}{:else if workspace.overlay === 'outline'}{:else if workspace.overlay === 'trash'}{:else if workspace.overlay === 'history'}{:else if workspace.overlay === 'backlinks'}{:else}{/if} + {#if workspace.overlay === 'help'}{:else if workspace.overlay === 'overview'}{:else if workspace.overlay === 'agenda'}{:else if workspace.overlay === 'actions'}{:else if workspace.overlay === 'verify'}{:else if workspace.overlay === 'outline'}{:else if workspace.overlay === 'trash'}{:else if workspace.overlay === 'history'}{:else if workspace.overlay === 'backlinks'}{:else if workspace.overlay === 'calendar'}{:else}{/if}
{/if} diff --git a/web/src/lib/Agenda.svelte b/web/src/lib/Agenda.svelte index 64e9b5c..63cc575 100644 --- a/web/src/lib/Agenda.svelte +++ b/web/src/lib/Agenda.svelte @@ -5,7 +5,7 @@ * had today yet. The keys live in App.svelte with the others; this draws * and lets a pointer do the same. */ - import type { AgendaCard } from './api' + import type { AgendaCard, DiaryEntry } from './api' import { workspace } from './store.svelte' import { runCommand } from './commands' import Icon from './Icon.svelte' @@ -23,6 +23,12 @@ sheet?.querySelector(`[data-index="${index}"]`)?.scrollIntoView({ block: 'nearest' }) }) + /** An entry from an earlier year, in place in the diary. */ + function open(entry: DiaryEntry) { + workspace.overlay = 'none' + void workspace.guard(() => workspace.openPath(entry.path)) + } + /** Where a card sits in the walked order, across the groups. */ function indexOf(card: AgendaCard) { return workspace.agendaCards.findIndex((c) => c.id === card.id) @@ -80,10 +86,33 @@

nothing late, nothing due this week, nothing in hand. c captures the next thing.

{/each} + {#if workspace.agenda.on_this_day.length} +
+

on this day {workspace.agenda.on_this_day.length}

+
    + {#each workspace.agenda.on_this_day as entry, at (entry.id)} + {@const index = total + at} +
  • + +
  • + {/each} +
+
+ {/if} +