From 7f8a3816d3d2f17dcd7979af9d1b905be5a296af Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Thu, 22 Oct 2026 03:10:49 +0200 Subject: [PATCH] fixes: what the audit turned up - POST /api/diary/day finds a day's entry, or starts it at the root of the diary, by the server's clock. T, the calendar and a capture all use it, so they can no longer write two entries for one day: before, T counted days by the browser's midnight and wrote into whichever diary folder was last open. - A Ctrl-P result opens beside the results, as a / result does, so n and N go on from it rather than finding no search. - A card's description resolves its [[links]] the way a document does, and following one leaves the card for what it leads to. - gf on the board opens a card's notes. Enter was documented for that, but Enter turns the card over. - The pane header and the status line show where the open node is, rather than where the list is, so a node from another space shows its own path. --- README.md | 4 +- docs/wiki/API.md | 1 + docs/wiki/Boards-and-Agenda.md | 3 +- docs/wiki/Commands.md | 5 +- docs/wiki/Guide.md | 5 +- src/notify/mod.rs | 2 +- src/routes/boards.rs | 67 ++++++++++++-- src/routes/capture.rs | 77 ++++------------ src/routes/diary.rs | 155 +++++++++++++++++++++++++++++++- src/routes/links.rs | 12 ++- src/routes/mod.rs | 1 + web/src/App.svelte | 6 ++ web/src/lib/CardSheet.svelte | 17 +++- web/src/lib/DocumentPane.svelte | 5 +- web/src/lib/FilePane.svelte | 6 +- web/src/lib/Help.svelte | 3 +- web/src/lib/Rail.svelte | 2 +- web/src/lib/StatusBar.svelte | 4 +- web/src/lib/api.ts | 9 ++ web/src/lib/commands.ts | 20 +---- web/src/lib/store.svelte.ts | 28 ++++-- web/src/lib/util.ts | 3 + 22 files changed, 321 insertions(+), 114 deletions(-) diff --git a/README.md b/README.md index 2ac67c3..a7519e0 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,7 @@ it; in the document pane it is that document. In the browse pane: | `Esc`, `q` | close the document, and its tile when there are several | | `h` | up one level in the tree | | `/` | full-text search in this space (`:noh` clears it); `Tab` or `Ctrl-N`/`Ctrl-P` walk the results without leaving the line, `Enter` opens the one lit | -| `Ctrl-P` | full-text search across every space; `Enter` goes to the match | +| `Ctrl-P` | full-text search across every space; `Enter` opens the match beside the results | | `n` / `N` | the next / previous search result, opened beside the results; past the last it starts again at the first | | `s` / `y` | toggle sharing / mint a new share link — either asks first when it would cut readers off | | `x`, `dd` | delete the entry under the cursor into the trash (asks `y`/`N`) | @@ -271,6 +271,7 @@ you were. On the board, `o` or `c` puts a card on the column under the cursor. | `J` / `K` | move the card down / up its list | | `H` / `L` | move the card to the list on the left / right | | `Enter` | turn the card over: labels, a markdown description, a checklist | +| `gf` | the card's notes, a document made on first use; `q` comes back | | `Space` | into the done column, or back out to the inbox | | `o`, `c` | a new card on this list | | `D` | due date — `fri`, `tm`, `+3d`, `+2w`, `10-31`, `2026-10-31` | @@ -869,6 +870,7 @@ sealed or not. | `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, whether the diary has today, its entries `on_this_day` in earlier years, and `today` in `WORKSPACE_TIMEZONE`, with its bounds | | `GET` | `/api/diary/days?month=2026-10` | the diary's entries in a month, each with its local `day` | +| `POST` | `/api/diary/day` | `{ day? }`: the entry of `2026-10-22`, or of today in `WORKSPACE_TIMEZONE`, started from `templates/day` when there is none; answers `{ id, created }` | | `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 | diff --git a/docs/wiki/API.md b/docs/wiki/API.md index 46eaa69..9f407d8 100644 --- a/docs/wiki/API.md +++ b/docs/wiki/API.md @@ -22,6 +22,7 @@ sealed or not. | `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, whether the diary has today, its entries `on_this_day` in earlier years, and `today` in `WORKSPACE_TIMEZONE`, with its bounds | | `GET` | `/api/diary/days?month=2026-10` | the diary's entries in a month, each with its local `day` | +| `POST` | `/api/diary/day` | `{ day? }`: the entry of `2026-10-22`, or of today in `WORKSPACE_TIMEZONE`, started from `templates/day` when there is none; answers `{ id, created }` | | `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 | diff --git a/docs/wiki/Boards-and-Agenda.md b/docs/wiki/Boards-and-Agenda.md index fda2bd6..2ab9b35 100644 --- a/docs/wiki/Boards-and-Agenda.md +++ b/docs/wiki/Boards-and-Agenda.md @@ -58,6 +58,7 @@ a week fold away until `z` shows them. | `Space` | into the done list, or back out to the inbox | | `o`, `c` | a new card on this list | | `Enter` | turn the card over: labels, description, checklist | +| `gf` | the card's notes, without turning it over | | `D` | due date | | `R` | rename the card | | `z` | show done cards older than a week | @@ -106,7 +107,7 @@ you stay where you were. From outside the browser, see [Capture](Capture.md). ### Notes -`gf` on the back of a card opens its notes: a document named after the card, made in the +`gf` on a card, or on its back, opens its notes: a document named after the card, made in the root of the space the first time. Closing it goes back to the board, on the same card. `:card!` while reading a document makes a card that points at it. diff --git a/docs/wiki/Commands.md b/docs/wiki/Commands.md index 2fd3499..2c4e1b2 100644 --- a/docs/wiki/Commands.md +++ b/docs/wiki/Commands.md @@ -48,7 +48,7 @@ arguments and `↑` / `↓` recall earlier lines. | `T` | today's diary entry, started if there is none — from templates/day when the diary has one | | `C` | the diary by month, in the special workspace: h/l a day, j/k a week, H/L a month, . today, Enter opens or starts the day | | `[d / ]d` | the diary entry before / after the one open, by date | -| `Ctrl-P` | find anything, in every space — Enter goes to the result lit | +| `Ctrl-P` | find anything, in every space — Enter opens the result lit beside them, n goes on | | `b` | the board, in the special workspace: this space's, or the last one shown — 1 … 9 there shows another's | | `c` | capture a card into this space's inbox, without leaving here | | `Esc · q` | close the document, and its tile when there are several | @@ -77,6 +77,7 @@ The first list is the inbox, the last list is done. | `H / L` | move the card to the list left / right | | `Space` | into the done list, or back out to the inbox | | `Enter` | turn the card over: its labels, description and checklist | +| `gf` | the card's notes — a document, made on first use; q comes back | | `o · c` | new card on this list | | `D` | due date: fri, tm, +3d, 10-31 … | | `R` | rename the card, or the list when it is empty | @@ -127,7 +128,7 @@ do: `:w!`, `:space! `, `:import!`. | `:board` | this space's board, in the special workspace | | `:special` | the special workspace: the board, the agenda and the calendar side by side — S puts it up and away, Tab walks its tiles, 1 … 9 picks the board | | `:card, :c ` | add a card — on the board to the list under the cursor, elsewhere to this space's inbox | -| `:card!` | add a card linked to the open document — Enter on it opens the document | +| `:card!` | add a card linked to the open document — gf on it opens the document | | `:due <when\|->` | due date of the card — on the board, or highlighted in the agenda: today, tm, fri, +3d, +2w, 10-31, 2026-10-31 — - clears | | `:repeat <rule\|->` | once the card is done, a copy comes back in the inbox: daily, weekly, monthly, yearly, +3d, +2w, +6m — - stops it | | `:done` | move the selected card into the done list, or back out to the inbox | diff --git a/docs/wiki/Guide.md b/docs/wiki/Guide.md index e7517e4..7a5ce37 100644 --- a/docs/wiki/Guide.md +++ b/docs/wiki/Guide.md @@ -93,8 +93,9 @@ shows the board of another space. The first list is the inbox and the last is done; the ones between are work in hand. `h`/`l` move between lists and `j`/`k` between cards. `H`/`L` move a -card across, `Space` finishes it, `D` sets its due date, and `Enter` opens its -notes: a document made for the card the first time you ask. `:repeat weekly` +card across, `Space` finishes it, and `D` sets its due date. `Enter` turns a +card over to its labels, description and checklist, and `gf` opens its notes: +a document made for the card the first time you ask. `:repeat weekly` makes a card come back in the inbox each time it is finished. `q` goes back to the tree. diff --git a/src/notify/mod.rs b/src/notify/mod.rs index 0fb509c..a05c5a9 100644 --- a/src/notify/mod.rs +++ b/src/notify/mod.rs @@ -490,7 +490,7 @@ pub(crate) fn local_day(tz: Tz, at: i64) -> NaiveDate { /// DST makes this a question with two awkward answers: the hour that occurs /// twice, where the earlier one is meant, and the hour that does not occur at /// all, where the clock skipping past it counts as the time having passed. -fn local_instant(tz: Tz, day: NaiveDate, hour: u32, minute: u32) -> i64 { +pub(crate) fn local_instant(tz: Tz, day: NaiveDate, hour: u32, minute: u32) -> i64 { let Some(naive) = day.and_hms_opt(hour, minute, 0) else { return i64::MAX; }; diff --git a/src/routes/boards.rs b/src/routes/boards.rs index 8989ae1..6837cfc 100644 --- a/src/routes/boards.rs +++ b/src/routes/boards.rs @@ -20,7 +20,7 @@ use axum::{ extract::{Path, State}, Json, }; -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use chrono_tz::Tz; use serde::{Deserialize, Serialize}; @@ -30,6 +30,7 @@ use crate::{ auth::Session, error::{AppError, AppResult}, routes::{ + links, manifest::ListMeta, nodes::{DOCUMENT, SPACE}, repeat::Repeat, @@ -75,6 +76,8 @@ pub struct Card { pub position: i64, pub created_at: i64, pub updated_at: i64, + /// Where each `[[title]]` in the description leads, as a document's do. + pub wikilinks: BTreeMap<String, String>, } /// A step on a card's checklist. @@ -385,6 +388,7 @@ fn row_to_card(row: &sqlx::sqlite::SqliteRow) -> Card { position: row.get("position"), created_at: row.get("created_at"), updated_at: row.get("updated_at"), + wikilinks: BTreeMap::new(), } } @@ -633,6 +637,11 @@ async fn load(state: &AppState, space_id: i64, space_name: String) -> AppResult< .fetch_all(&state.db) .await?; + let mut cards: Vec<Card> = card_rows.iter().map(row_to_card).collect(); + let mut conn = state.db.acquire().await?; + for card in cards.iter_mut().filter(|card| card.body.contains("[[")) { + card.wikilinks = links::wikilinks_in(&mut conn, Some(space_id), &card.body).await?; + } let lists = list_rows .iter() .map(|row| { @@ -641,11 +650,7 @@ async fn load(state: &AppState, space_id: i64, space_name: String) -> AppResult< id, name: row.get("name"), position: row.get("position"), - cards: card_rows - .iter() - .filter(|card| card.get::<i64, _>("list_id") == id) - .map(row_to_card) - .collect(), + cards: cards.extract_if(.., |card| card.list_id == id).collect(), } }) .collect(); @@ -1187,12 +1192,58 @@ async fn card(db: &SqlitePool, id: i64) -> AppResult<Card> { #[cfg(test)] mod tests { use super::{ - agenda_cards, card, done_after_move, relocate, reordered, restore, settle, tidy_checklist, - tidy_labels, Task, + agenda_cards, card, done_after_move, load, relocate, reordered, restore, settle, + tidy_checklist, tidy_labels, Task, }; use crate::routes::manifest::{CardMeta, ListMeta}; use std::collections::HashMap; + #[tokio::test] + async fn a_description_links_by_title_as_a_document_does() { + let state = crate::routes::tests::state_in(".".into()).await; + let db = &state.db; + let work: i64 = sqlx::query_scalar("SELECT id FROM nodes WHERE slug = 'work'") + .fetch_one(db) + .await + .unwrap(); + sqlx::query( + "INSERT INTO nodes (parent_id, kind, name, slug, body, created_at, updated_at) + VALUES (?1, 'document', 'Runbook', 'runbook', '', 0, 0)", + ) + .bind(work) + .execute(db) + .await + .unwrap(); + let list: i64 = sqlx::query_scalar( + "INSERT INTO board_lists (space_id, name, position, created_at) + VALUES (?1, 'inbox', 0, 0) RETURNING id", + ) + .bind(work) + .fetch_one(db) + .await + .unwrap(); + sqlx::query( + "INSERT INTO cards (list_id, title, body, position, created_at, updated_at) + VALUES (?1, 'rotate', 'see [[Runbook]], then [[Nowhere]]', 0, 0, 0)", + ) + .bind(list) + .execute(db) + .await + .unwrap(); + + let board = load(&state, work, "work".into()).await.unwrap(); + let card = board + .lists + .iter() + .flat_map(|list| &list.cards) + .find(|card| card.list_id == list) + .unwrap(); + assert_eq!( + card.wikilinks.iter().collect::<Vec<_>>(), + [(&"Runbook".to_string(), &"work/runbook".to_string())] + ); + } + /// A space with a board of `inbox`, `doing`, `done` on a fresh database. async fn board() -> (sqlx::SqlitePool, i64, Vec<i64>) { let db = sqlx::sqlite::SqlitePoolOptions::new() diff --git a/src/routes/capture.rs b/src/routes/capture.rs index a10fa82..1c58ebc 100644 --- a/src/routes/capture.rs +++ b/src/routes/capture.rs @@ -14,9 +14,7 @@ use crate::{ error::{AppError, AppResult}, routes::{ boards::{self, CardInput}, - diary, links, media, - nodes::{self, slugify, unique_slug}, - revisions, + diary, links, media, nodes, revisions, }, state::AppState, }; @@ -94,65 +92,28 @@ async fn card(db: &SqlitePool, text: &str, space: Option<&str>) -> AppResult<Cap }) } -/// `- 14:05 text`, at the end of today's entry: the first document written -/// today at the top of the diary, the one `:today` opens. With none yet, the -/// line starts one the way `:today` would, from `templates/day` if there is one. +/// `- 14:05 text`, at the end of today's entry: the one `T` opens. With none +/// yet, the line starts it the way `T` would, from `templates/day` if there +/// is one. async fn diary(db: &SqlitePool, tz: Tz, text: &str, now: i64) -> AppResult<Captured> { let line = format!("- {} {}", clock(tz, now), item(text)); let mut tx = crate::db::begin_write(db).await?; - let diary: i64 = sqlx::query_scalar("SELECT id FROM nodes WHERE role = ?1") - .bind(nodes::DIARY) - .fetch_optional(&mut *tx) - .await? - .ok_or_else(|| AppError::BadRequest("there is no diary to write in".into()))?; - let (start, end) = crate::notify::day_bounds(tz, crate::notify::local_day(tz, now)); - let today: Option<(i64, String)> = sqlx::query_as( - "SELECT id, body FROM nodes - WHERE parent_id = ?1 AND kind = 'document' AND created_at >= ?2 AND created_at < ?3 - ORDER BY created_at, id - LIMIT 1", - ) - .bind(diary) - .bind(start) - .bind(end) - .fetch_optional(&mut *tx) - .await?; - - let id = match today { - Some((id, body)) => { - let body = appended(&body, &line); - revisions::keep(&mut tx, id, &body, revisions::Reason::Edit).await?; - sqlx::query("UPDATE nodes SET body = ?1, updated_at = ?2 WHERE id = ?3") - .bind(&body) - .bind(now) - .bind(id) - .execute(&mut *tx) - .await?; - media::link_to_node(&mut tx, id, &body).await?; - links::index(&mut tx, id, &body).await?; - id - } - None => { - 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) - VALUES (?1, 'document', '', ?2, ?3, ?4, ?4) - RETURNING id", - ) - .bind(diary) - .bind(&slug) - .bind(&body) - .bind(now) - .fetch_one(&mut *tx) - .await?; - media::link_to_node(&mut tx, id, &body).await?; - links::index(&mut tx, id, &body).await?; - id - } - }; + let today = diary::entry_of(&mut tx, tz, crate::notify::local_day(tz, now), now).await?; + let id = today.id; + let body = appended(&today.body, &line); + // What an entry just started replaces is its template, not worth keeping. + if !today.created { + revisions::keep(&mut tx, id, &body, revisions::Reason::Edit).await?; + } + sqlx::query("UPDATE nodes SET body = ?1, updated_at = ?2 WHERE id = ?3") + .bind(&body) + .bind(now) + .bind(id) + .execute(&mut *tx) + .await?; + media::link_to_node(&mut tx, id, &body).await?; + links::index(&mut tx, id, &body).await?; tx.commit().await?; let place = nodes::path_of(db, id) diff --git a/src/routes/diary.rs b/src/routes/diary.rs index 4a37bf6..dd1ad65 100644 --- a/src/routes/diary.rs +++ b/src/routes/diary.rs @@ -18,8 +18,11 @@ 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}, + notify::{day_bounds, local_day, local_instant, weekday_name}, + routes::{ + links, media, + nodes::{excerpt, path_of, slugify, unique_slug, HEAD_CHARS}, + }, state::AppState, }; @@ -321,6 +324,109 @@ pub(crate) async fn is_diary(conn: &mut SqliteConnection, id: i64) -> AppResult< Ok(role.flatten().as_deref() == Some(crate::routes::nodes::DIARY)) } +/// The entry about the local day `day`: the first written, wherever in the +/// diary it was filed. With none, a new one at the root of the diary, from +/// `templates/day` when there is one, dated `at` — or, for another day than +/// the one `at` falls on, noon of it. `T`, the calendar and a capture all ask +/// this, so they find and start the same entry, by the server's clock. +pub(crate) async fn entry_of( + conn: &mut SqliteConnection, + tz: Tz, + day: NaiveDate, + at: i64, +) -> AppResult<Day> { + let diary: i64 = sqlx::query_scalar("SELECT id FROM nodes WHERE role = ?1") + .bind(crate::routes::nodes::DIARY) + .fetch_optional(&mut *conn) + .await? + .ok_or_else(|| AppError::BadRequest("there is no diary to write in".into()))?; + let (start, end) = day_bounds(tz, day); + let found: Option<(i64, String)> = sqlx::query_as(&format!( + "WITH RECURSIVE {ENTRIES} + SELECT id, body 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 + LIMIT 1" + )) + .bind(start) + .bind(end) + .fetch_optional(&mut *conn) + .await?; + if let Some((id, body)) = found { + return Ok(Day { + id, + body, + created: false, + }); + } + + let at = if local_day(tz, at) == day { + at + } else { + local_instant(tz, day, 12, 0) + }; + let body = day_template(conn, tz, diary, at).await?.unwrap_or_default(); + let slug = unique_slug(&mut *conn, Some(diary), &slugify(""), None).await?; + let id: i64 = sqlx::query_scalar( + "INSERT INTO nodes (parent_id, kind, name, slug, body, created_at, updated_at) + VALUES (?1, 'document', '', ?2, ?3, ?4, ?5) + RETURNING id", + ) + .bind(diary) + .bind(&slug) + .bind(&body) + .bind(at) + .bind(crate::now()) + .fetch_one(&mut *conn) + .await?; + media::link_to_node(conn, id, &body).await?; + links::index(conn, id, &body).await?; + Ok(Day { + id, + body, + created: true, + }) +} + +/// A day's entry, and whether asking for it is what started it. +#[derive(Serialize, Debug)] +pub struct Day { + pub id: i64, + #[serde(skip)] + pub body: String, + pub created: bool, +} + +#[derive(Deserialize)] +pub struct DayInput { + /// `2026-10-22`; today, in `WORKSPACE_TIMEZONE`, without it. + #[serde(default)] + pub day: Option<String>, +} + +/// `POST /api/diary/day`: the entry of a day, started when there is none. +pub async fn day( + _: Session, + State(state): State<AppState>, + Json(input): Json<DayInput>, +) -> AppResult<Json<Day>> { + let tz = state.config.mail.timezone; + let now = crate::now(); + let day = match input.day.as_deref() { + Some(day) => NaiveDate::parse_from_str(day, "%Y-%m-%d") + .map_err(|_| AppError::BadRequest(format!("not a day: {day}, but 2026-10-22 is")))?, + None => local_day(tz, now), + }; + let mut tx = crate::db::begin_write(&state.db).await?; + let entry = entry_of(&mut tx, tz, day, now).await?; + tx.commit().await?; + if entry.created { + state.backup.signal(); + } + Ok(Json(entry)) +} + #[cfg(test)] mod tests { use axum::{extract::State, Json}; @@ -328,7 +434,7 @@ mod tests { use chrono_tz::Europe::Berlin; use sqlx::SqlitePool; - use super::{around, fill, month_entries, on_this_day}; + use super::{around, entry_of, 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 { @@ -459,6 +565,49 @@ mod tests { assert_eq!(body, "## Thursday\n\n- 07:00 rain\n"); } + #[tokio::test] + async fn a_day_has_one_entry_found_by_the_servers_clock() { + let state = crate::routes::tests::state_in(".".into()).await; + let db = &state.db; + let diary = diary(db).await; + // 23:30 in UTC on the 21st is already the 22nd here. + let now = at(2026, 10, 22, 1, 30); + let day = |d| chrono::NaiveDate::from_ymd_opt(2026, 10, d).unwrap(); + + // An entry filed in a folder is still the day's. + let folder = add(db, diary, "folder", "october", "", 0).await; + let filed = add(db, folder, "document", "", "", at(2026, 10, 22, 0, 10)).await; + let templates = add(db, diary, "folder", "templates", "", 0).await; + add(db, templates, "document", "day", "{{weekday}}\n", 0).await; + let mut conn = db.acquire().await.unwrap(); + let found = entry_of(&mut conn, Berlin, day(22), now).await.unwrap(); + assert_eq!((found.id, found.created), (filed, false)); + + // A day without one gets one at the root, at noon, from the template. + let started = entry_of(&mut conn, Berlin, day(20), now).await.unwrap(); + assert!(started.created); + assert_eq!(started.body, "Tuesday\n"); + let (parent, created_at): (i64, i64) = + sqlx::query_as("SELECT parent_id, created_at FROM nodes WHERE id = ?1") + .bind(started.id) + .fetch_one(&mut *conn) + .await + .unwrap(); + assert_eq!((parent, created_at), (diary, at(2026, 10, 20, 12, 0))); + // Asked again, it is found rather than started twice. + let again = entry_of(&mut conn, Berlin, day(20), now).await.unwrap(); + assert_eq!((again.id, again.created), (started.id, false)); + + // Today's is dated now, and a capture writes into the same one. + let today = entry_of(&mut conn, Berlin, day(21), at(2026, 10, 21, 9, 0)) + .await + .unwrap(); + drop(conn); + let captured = + crate::routes::capture::tests::diary_at(db, "rain", at(2026, 10, 21, 18, 0)).await; + assert_eq!(captured, today.id); + } + #[tokio::test] async fn a_month_holds_the_days_of_the_timezone() { let state = crate::routes::tests::state_in(".".into()).await; diff --git a/src/routes/links.rs b/src/routes/links.rs index ca103bb..b3557a6 100644 --- a/src/routes/links.rs +++ b/src/routes/links.rs @@ -431,6 +431,17 @@ pub async fn wikilinks( db: &mut SqliteConnection, from: i64, body: &str, +) -> AppResult<BTreeMap<String, String>> { + let space = space_of(db, from).await?; + wikilinks_in(db, space, body).await +} + +/// Where the `[[titles]]` in `body` lead, read from the space `space`: a +/// card's description, which is in a space without being a node in it. +pub async fn wikilinks_in( + db: &mut SqliteConnection, + space: Option<i64>, + body: &str, ) -> AppResult<BTreeMap<String, String>> { let mut found = BTreeMap::new(); let titles: Vec<String> = written(body) @@ -441,7 +452,6 @@ pub async fn wikilinks( if titles.is_empty() { return Ok(found); } - let space = space_of(db, from).await?; for title in titles { if let Some(to) = resolve_title(db, &title, space).await? { found.insert(title, path_text(db, to).await?); diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 2591820..88dec02 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -62,6 +62,7 @@ pub fn api_router(max_upload: usize) -> Router<AppState> { .route("/spaces/overview", get(nodes::overview)) .route("/agenda", get(boards::agenda)) .route("/diary/days", get(diary::days)) + .route("/diary/day", post(diary::day)) .route("/diary/around/{id}", get(diary::around_entry)) .route("/capture", post(capture::capture)) .route("/scratch", get(scratch::list).post(scratch::create)) diff --git a/web/src/App.svelte b/web/src/App.svelte index 846932a..fdf5783 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -1106,6 +1106,12 @@ workspace.cardCursor = 0 return } + // The card's notes, without turning it over first: `gf` as on its back. + if (previous === 'g' && key === 'f') { + event.preventDefault() + void workspace.guard(() => workspace.openCard()) + return + } if (previous === 'd' && key === 'd') { event.preventDefault() void runCommand('delete') diff --git a/web/src/lib/CardSheet.svelte b/web/src/lib/CardSheet.svelte index 16efb21..d656019 100644 --- a/web/src/lib/CardSheet.svelte +++ b/web/src/lib/CardSheet.svelte @@ -5,6 +5,7 @@ * description, the checklist. The keys live in App.svelte with the others; * this draws and lets a pointer do the same. */ + import { followLink } from './commands' import { renderMarkdown } from './markdown' import { workspace } from './store.svelte' import { formatDay, labelHue, urgency } from './util' @@ -16,7 +17,7 @@ const lists = $derived(workspace.board?.lists.length ?? 0) const role = $derived(lists < 2 ? '' : workspace.listCursor === 0 ? 'inbox' : workspace.listCursor === lists - 1 ? 'done' : '') const ticked = $derived(card?.checklist.filter((task) => task.done).length ?? 0) - const html = $derived(card?.body.trim() ? renderMarkdown(card.body) : '') + const html = $derived(card?.body.trim() ? renderMarkdown(card.body, { wikilinks: card.wikilinks }) : '') // A card deleted or moved out of sight under the sheet takes the sheet with it. $effect(() => { @@ -28,6 +29,16 @@ steps?.querySelector<HTMLElement>(`[data-index="${at}"]`)?.scrollIntoView({ block: 'nearest' }) }) + /** A link in the description leaves the card for what it leads to, as one in a document does. */ + function follow(event: MouseEvent) { + if (event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0) return + const href = (event.target as HTMLElement | null)?.closest('a')?.getAttribute('href') ?? '' + if (!href.startsWith('/n/')) return + event.preventDefault() + workspace.closeCardSheet() + followLink(href) + } + function pick(index: number) { workspace.taskCursor = index } @@ -81,8 +92,8 @@ /> {/await} {:else if html} - <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions --> - <article class="md description" ondblclick={() => workspace.describe()}> + <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions, a11y_no_noninteractive_element_interactions --> + <article class="md description" onclick={follow} ondblclick={() => workspace.describe()}> <!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitised in renderMarkdown --> {@html html} </article> diff --git a/web/src/lib/DocumentPane.svelte b/web/src/lib/DocumentPane.svelte index 3955550..4af0dff 100644 --- a/web/src/lib/DocumentPane.svelte +++ b/web/src/lib/DocumentPane.svelte @@ -14,6 +14,7 @@ relative, scrollPage, scrollWide, + slugPath, } from './util' /** The tile this pane draws; the keys only reach it while it has focus. */ @@ -143,13 +144,13 @@ <section class="pane"> <header> - <div class="crumb label rule" title="{workspace.here}/{doc.slug}"> + <div class="crumb label rule" title="{slugPath(doc.path)}"> <!-- A phone has no crumbs on the bar and no h key: this is the way back. --> <button class="back" aria-label="back to the list" onclick={() => runCommand('q')}> <Icon name="back" size={16} /> </button> <span class="marker" class:live={tile.editing}></span> - <span class="path">{workspace.here}/{doc.slug}</span> + <span class="path">{slugPath(doc.path)}</span> {#if tile.dirty}<span class="dirty">modified</span>{/if} </div> diff --git a/web/src/lib/FilePane.svelte b/web/src/lib/FilePane.svelte index 0ddcdfb..74599fe 100644 --- a/web/src/lib/FilePane.svelte +++ b/web/src/lib/FilePane.svelte @@ -5,7 +5,7 @@ import { renderMarkdown } from './markdown' import { looksLikeText, parseTable, previewKind, separatorOf } from './preview' import { workspace, type Tile } from './store.svelte' - import { formatBytes, formatStamp, headingsIn, jumpHeading, readerHeadings, relative, scrollPage, scrollWide } from './util' + import { formatBytes, formatStamp, headingsIn, jumpHeading, readerHeadings, relative, scrollPage, scrollWide, slugPath } from './util' /** The tile this pane draws; the keys only reach it while it has focus. */ const { tile }: { tile: Tile } = $props() @@ -158,12 +158,12 @@ <section class="pane"> <header> - <div class="crumb label rule" title="{workspace.here}/{node.slug}"> + <div class="crumb label rule" title="{slugPath(node.path)}"> <button class="back" aria-label="back to the list" onclick={() => runCommand('q')}> <Icon name="back" size={16} /> </button> <span class="marker"></span> - <span class="path">{workspace.here}/{node.slug}</span> + <span class="path">{slugPath(node.path)}</span> </div> <div class="row"> diff --git a/web/src/lib/Help.svelte b/web/src/lib/Help.svelte index 5773341..e201119 100644 --- a/web/src/lib/Help.svelte +++ b/web/src/lib/Help.svelte @@ -78,7 +78,7 @@ ['T', "today's diary entry, started if there is none — from templates/day when the diary has one"], ['C', 'the diary by month, in the special workspace: h/l a day, j/k a week, H/L a month, . today, Enter opens or starts the day'], ['[d / ]d', 'the diary entry before / after the one open, by date'], - ['Ctrl-P', 'find anything, in every space — Enter goes to the result lit'], + ['Ctrl-P', 'find anything, in every space — Enter opens the result lit beside them, n goes on'], ['b', "the board, in the special workspace: this space's, or the last one shown — 1 … 9 there shows another's"], ['c', "capture a card into this space's inbox, without leaving here"], ['Esc · q', 'close the document, and its tile when there are several'], @@ -107,6 +107,7 @@ ['H / L', 'move the card to the list left / right'], ['Space', 'into the done list, or back out to the inbox'], ['Enter', 'turn the card over: its labels, description and checklist'], + ['gf', "the card's notes — a document, made on first use; q comes back"], ['o · c', 'new card on this list'], ['D', 'due date: fri, tm, +3d, 10-31 …'], ['R', 'rename the card, or the list when it is empty'], diff --git a/web/src/lib/Rail.svelte b/web/src/lib/Rail.svelte index 2367253..f5093db 100644 --- a/web/src/lib/Rail.svelte +++ b/web/src/lib/Rail.svelte @@ -55,7 +55,7 @@ { icon: 'right', label: 'Send the card right', keys: ['L'], run: () => workspace.guard(() => workspace.sendCard(1)), dim: !card }, { icon: 'calendar', label: 'Due date', keys: ['D'], run: () => hooks.openCommandLine(':due '), dim: !card }, { icon: 'repeat', label: 'Repeat once done', keys: [':repeat'], run: () => hooks.openCommandLine(':repeat '), dim: !card }, - { icon: 'doc', label: "The card's notes", keys: ['Enter'], run: () => workspace.guard(() => workspace.openCard()), dim: !card }, + { icon: 'doc', label: "The card's notes", keys: ['gf'], run: () => workspace.guard(() => workspace.openCard()), dim: !card }, ], [ { icon: 'up', label: 'Card up its list', keys: ['K'], run: () => workspace.guard(() => workspace.nudgeCard(-1)), dim: !card }, diff --git a/web/src/lib/StatusBar.svelte b/web/src/lib/StatusBar.svelte index 935f297..038e31c 100644 --- a/web/src/lib/StatusBar.svelte +++ b/web/src/lib/StatusBar.svelte @@ -1,7 +1,7 @@ <script lang="ts"> import Rail from './Rail.svelte' import { workspace } from './store.svelte' - import { isTouchDevice } from './util' + import { isTouchDevice, slugPath } from './util' /** A key waiting for its second half, like vim's `showcmd`. */ const { pending = '' }: { pending?: string } = $props() @@ -12,7 +12,7 @@ // The path is the file name here: work/einarbeiten/01-was-ist-eebus. const file = $derived( - workspace.open ? `${workspace.here}/${workspace.open.slug}` : `${workspace.here || '~'}/`, + workspace.open ? slugPath(workspace.open.path) : `${workspace.here || '~'}/`, ) const lines = $derived(workspace.editing ? workspace.draft.body.split('\n').length : null) diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index d1ab6e6..46cb8d1 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -176,6 +176,8 @@ export interface Card { position: number created_at: number updated_at: number + /** Where each `[[title]]` in the description leads, as a document's do. */ + wikilinks: Record<string, string> } /** A step on a card's checklist. */ @@ -492,6 +494,13 @@ export const api = { /** A month's diary entries: `2026-10`. */ diaryDays: (month: string) => request<DiaryEntry[]>(`/diary/days?month=${encodeURIComponent(month)}`), + /** + * The entry of a day, `2026-10-22`, or of today on the server's clock; + * started when there is none, which `created` says. + */ + diaryDay: (day?: string) => + request<{ id: number; created: boolean }>('/diary/day', { method: 'POST', body: body({ day }) }), + /** The entries before and after a diary entry, by date. */ diaryAround: (id: number) => request<{ prev: DiaryEntry | null; next: DiaryEntry | null }>(`/diary/around/${id}`), diff --git a/web/src/lib/commands.ts b/web/src/lib/commands.ts index 1cc263d..099ef79 100644 --- a/web/src/lib/commands.ts +++ b/web/src/lib/commands.ts @@ -465,7 +465,7 @@ export const commands: CommandSpec[] = [ await workspace.guard(() => workspace.createCard(arg.trim())) }, bang: { - help: 'add a card linked to the open document — Enter on it opens the document', + help: 'add a card linked to the open document — gf on it opens the document', run: async (arg) => { // On the board the tree is not on screen, so only the open document // can be meant; elsewhere it is whatever has focus. @@ -688,23 +688,7 @@ export const commands: CommandSpec[] = [ group: 'documents', help: "open today's diary entry, starting one if there is none yet", run: async () => { - await workspace.guard(async () => { - // The diary is one space among several now, so :today goes there first - // rather than writing the day into whatever folder was being browsed. - const home = workspace.spaces.find((space) => space.role === 'diary') - if (home && workspace.parentId !== home.id) await workspace.enterSpace(home.id) - else if (workspace.query) await workspace.search('') - - const midnight = new Date() - midnight.setHours(0, 0, 0, 0) - const from = Math.floor(midnight.getTime() / 1000) - const today = workspace.nodes.find( - (node) => - node.kind === 'document' && node.created_at >= from && node.created_at < from + 86400, - ) - if (today) await workspace.openNode(today.id, true) - else await workspace.createDocument() - }) + await workspace.guard(() => workspace.openDay()) hooks.focusEditor() }, }, diff --git a/web/src/lib/store.svelte.ts b/web/src/lib/store.svelte.ts index 691d9be..a98d264 100644 --- a/web/src/lib/store.svelte.ts +++ b/web/src/lib/store.svelte.ts @@ -981,11 +981,8 @@ class Workspace { async openSelected(edit = false) { const selected = this.selected if (!selected) return - // A match from anywhere is gone to, not opened beside a list it is not in. - if (this.globalSearch) { - const node = await api.getNode(selected.id) - return this.openPath(node.path.map((crumb) => crumb.slug).join('/')) - } + // A match from anywhere opens beside the results, as one from this space + // does, so `n` goes on from it; only a folder ends the search, by going in. if (isContainer(selected)) await this.goTo(selected.id) else await this.openNode(selected.id, edit) } @@ -1383,10 +1380,27 @@ class Workspace { const entry = this.calendarEntries.find((e) => e.day === day) // Opened from the special workspace, so `q` on the entry comes back here. if (entry) return this.openPath(entry.path) + await this.openDay(day) + } + + /** + * `T`, and a day in the calendar without an entry: the server finds the + * day's entry or starts it, by its own clock and at the root of the diary, + * as a capture does, so the three never write two entries for one day. + */ + async openDay(day?: string) { const home = this.spaces.find((space) => space.role === 'diary') if (!home) return this.say('there is no diary to write in', 'error') - if (this.parentId !== home.id) await this.enterSpace(home.id) - await this.createDocument('', { created_at: parseDay(day) ?? undefined }) + const entry = await api.diaryDay(day) + if (this.spaceId !== home.id) await this.enterSpace(home.id) + else if (this.query) await this.search('') + if (entry.created) await this.refresh() + await this.openNode(entry.id, true) + if (entry.created) { + // Left as it was started, it goes again, as a new document from `o` does. + this.tile.fresh = entry.id + this.enterInsert = true + } } /** `[d` / `]d`: the diary entry before or after the one being read. */ diff --git a/web/src/lib/util.ts b/web/src/lib/util.ts index 30f8189..c53ac94 100644 --- a/web/src/lib/util.ts +++ b/web/src/lib/util.ts @@ -258,6 +258,9 @@ export function linkAt(line: string, column: number): string | null { * Where a `[[title]]` points when the page cannot say where it leads: the * title itself, for following it to look it up, or to offer to write it. */ +/** `work/notes/plan`: where a node is, by its crumbs. */ +export const slugPath = (path: { slug: string }[]) => path.map((crumb) => crumb.slug).join('/') + export const titleHref = (title: string) => `/n/?title=${encodeURIComponent(title)}` /** What a share link's reader will miss: a share never serves a sealed file. */