diff --git a/README.md b/README.md index 4caf3aa..3d29501 100644 --- a/README.md +++ b/README.md @@ -128,11 +128,12 @@ it; in the document pane it is that document. In the browse pane: | `Ctrl-D` / `Ctrl-U` | half a page down / up the open document | | `zl` / `zh`, `zL` / `zH` | scroll the table, code block or formula at the top of the view sideways, a little or by half its width | | `]]` / `[[` | the next / previous heading of the open document, in the editor too | -| `gi` | the info sheet: the open document's headings, earlier versions, backlinks and share link — `Tab` goes from one part to the next (`:info`); clicking the line under the title opens it too | +| `gi` | the info sheet: the open document's headings, earlier versions, backlinks, cards and the share link — `Tab` goes from one part to the next (`:info`); clicking the line under the title opens it too | | `gO` | the info sheet at the headings — `j`/`k`, `Enter` jumps there (`:outline`) | | `gx` | open the file under the cursor in a tab of its own | | `gh` | the info sheet at the earlier versions, as diffs — `j`/`k`, `Enter` brings one back (`:history`) | | `gb` | the info sheet at the documents that link to the open one — `j`/`k`, `Enter` opens one (`:backlinks`) | +| `gc` | the info sheet at the cards about the open document — `j`/`k` picks one for `Space`, `H`/`L`, `:due` and `:label`, `Enter` turns it over on its board (`:cards`) | | `F` | a letter over every link and button in view; typing it follows it — links out of the workspace open in a tab of their own | | `Enter`, `l` | open the highlighted entry; on an open keystore or sealed file, `Enter` asks for its password | | `o` | a new document here, straight into insert mode | @@ -147,7 +148,7 @@ it; in the document pane it is that document. In the browse pane: | `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 | +| `c` | capture a card into this space's inbox without leaving where you are — reading a document, a card about it | | `Q` | the scratchpad, lowered over whatever is on screen — see [The scratchpad](docs/wiki/Writing.md#the-scratchpad) | | `Esc`, `q` | close the document, and its tile when there are several | | `h` | up one level in the tree | @@ -882,6 +883,7 @@ sealed or not. | `POST` | `/api/lists/{id}/cards` | add a card | | `PUT`/`DELETE` | `/api/cards/{id}` | update (title, body, due, repeat, checklist, labels, done, link) / delete; finishing a repeating card answers its copy as `again` | | `POST` | `/api/cards/{id}/move` | to another list and place in it; `again` as above | +| `GET` | `/api/cards?q=…` | cards whose title, summary or a checklist step matches, open ones first; `space` keeps it to one board | | `GET`/`POST` | `/api/scratch` | the scratchpad's notes, the one touched last first / a new note `{ body }` | | `PUT`/`DELETE` | `/api/scratch/{id}` | rewrite a note `{ body }` / drop it | | `POST` | `/api/nodes/{id}/move` | reparent, and place among the new siblings | @@ -892,6 +894,7 @@ sealed or not. | `GET` | `/api/nodes/{id}/revisions` | a document's earlier versions, the newest first | | `GET` | `/api/nodes/{id}/revisions/{rev}` | one earlier version, with its text | | `GET` | `/api/nodes/{id}/backlinks` | the documents that link to this one, with the line each does it on | +| `GET` | `/api/nodes/{id}/cards` | the cards about this document: its own (`node_id`) and the ones whose summary links here — also in the node itself as `cards` | | `POST`/`DELETE` | `/api/nodes/{id}/share` | mint or rotate / revoke a share link | | `GET` | `/api/export` | the whole workspace, boards and dates included, as a zip | | `GET`/`POST` | `/api/backup` | Proton Drive mirror: status / run now | diff --git a/docs/wiki/API.md b/docs/wiki/API.md index 9f407d8..9b33a74 100644 --- a/docs/wiki/API.md +++ b/docs/wiki/API.md @@ -33,6 +33,7 @@ sealed or not. | `POST` | `/api/lists/{id}/cards` | add a card | | `PUT`/`DELETE` | `/api/cards/{id}` | update (title, body, due, repeat, checklist, labels, done, link) / delete; finishing a repeating card answers its copy as `again` | | `POST` | `/api/cards/{id}/move` | to another list and place in it; `again` as above | +| `GET` | `/api/cards?q=…` | cards whose title, summary or a checklist step matches, open ones first; `space` keeps it to one board | | `GET`/`POST` | `/api/scratch` | the scratchpad's notes, the one touched last first / a new note `{ body }` | | `PUT`/`DELETE` | `/api/scratch/{id}` | rewrite a note `{ body }` / drop it | | `POST` | `/api/nodes/{id}/move` | reparent, and place among the new siblings | @@ -43,6 +44,7 @@ sealed or not. | `GET` | `/api/nodes/{id}/revisions` | a document's earlier versions, the newest first | | `GET` | `/api/nodes/{id}/revisions/{rev}` | one earlier version, with its text | | `GET` | `/api/nodes/{id}/backlinks` | the documents that link to this one, with the line each does it on | +| `GET` | `/api/nodes/{id}/cards` | the cards about this document: its own (`node_id`) and the ones whose summary links here — also in the node itself as `cards` | | `POST`/`DELETE` | `/api/nodes/{id}/share` | mint or rotate / revoke a share link | | `GET` | `/api/export` | the whole workspace, boards and dates included, as a zip | | `GET`/`POST` | `/api/backup` | Proton Drive mirror: status / run now | diff --git a/docs/wiki/Boards-and-Agenda.md b/docs/wiki/Boards-and-Agenda.md index e45b354..fba3a43 100644 --- a/docs/wiki/Boards-and-Agenda.md +++ b/docs/wiki/Boards-and-Agenda.md @@ -111,17 +111,44 @@ step open again. `c` in the tree opens `:card ` on the command line. The card goes into the inbox of the space you are in, or of the first space with a board, and -you stay where you were. From outside the browser, see [Capture](Capture.md). +you stay where you were. Reading a document, `c` opens `:card! ` instead: the +card is about that document, and an empty title takes the document's. From +outside the browser, see [Capture](Capture.md). ### Notes `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, in a tile beside the board. Closing it goes -back to the board, on the same card. `:card!` while reading a document makes a card that points at it. +back to the board, on the same card. The link is loose on purpose: deleting the document leaves the card, because the work is still open when its notes are gone. +### A document's cards + +A document knows the cards about it: the ones whose notes it is, and the ones +whose description links to it with a `[[title]]` or an `/n/` path. Under the +line below its title, a second line says what is still open about it +(`card write the plan · doing`, or `cards 3 open`) and the first date one is +due. Clicking it, or `gc`, opens the cards part of the info sheet. + +There, `j`/`k` picks a card. `Space` finishes it, `H`/`L` sends it along its +board, `D` or `:due` sets its date, and `:label` and `:repeat` work too, all +without going to the board. `Enter` turns it over on its board. `o` makes another +card about the document. + +Reading a document with just one open card about it, the card verbs act on that +card with the sheet closed: `:due fri` while reading the plan dates the plan's card. + +A card's description links the way a document's text does. Renaming or moving +a document rewrites the links to it in card descriptions too. + +### The card in focus + +`:due`, `:repeat`, `:label` and `:done` act on the card in focus: the one under +the board's cursor, the one turned over, the one highlighted in the agenda, or +a document's card as above. + ### Due dates `D` on a card (or `:due `) takes a date the way it is said: diff --git a/docs/wiki/Commands.md b/docs/wiki/Commands.md index 5569265..31d7acb 100644 --- a/docs/wiki/Commands.md +++ b/docs/wiki/Commands.md @@ -29,7 +29,7 @@ arguments and `↑` / `↓` recall earlier lines. | `Ctrl-D / Ctrl-U` | half a page down / up the open document | | `zl / zh · zL / zH` | the wide table or code block at the top of the view, sideways — a little, or half its width | | `]] / \[\[` | next / previous heading of the document — in the editor too | -| `gi` | the info sheet: the open document's headings, earlier versions, backlinks and sharing — Tab between them | +| `gi` | the info sheet: the open document's headings, earlier versions, backlinks, cards and sharing — Tab between them | | `gO` | the headings as a list: j/k, Enter jumps there | | `F` | hints over the links and buttons in view — type one to follow it | | `gf / gx` | in the editor: follow the link under the cursor / open it in a tab | @@ -50,7 +50,7 @@ arguments and `↑` / `↓` recall earlier lines. | `[d / ]d` | the diary entry before / after the one open, by date | | `Ctrl-P` | find anything, in every space — Enter opens the result lit beside them, n goes on | | `b` | the board, in a tile: 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 | +| `c` | capture a card into this space's inbox, without leaving here — reading a document, a card about it | | `Esc · q` | close the document, and its tile when there are several | | `/` | full-text search, inside the current space and its text files — Tab or Ctrl-N/P walk the results, Enter opens one | | `n / N` | next / previous search result, opened beside the results — :noh clears the search | @@ -60,6 +60,7 @@ arguments and `↑` / `↓` recall earlier lines. | `u` | undo the last delete: it comes back out of the trash — :trash has the rest | | `gh` | the open document's earlier versions, as diffs — Enter brings one back | | `gb` | the documents that link to the open one — \[\[title]] links by title, \[\[ completes one while writing | +| `gc` | the cards about the open document — j/k picks the one :due, :label, Space and H/L act on, Enter turns it over on its board | | `:` | command line — Tab / Shift-Tab cycle the matches, then the argument; ↑ ↓ recall what ran before | | `Ctrl-S` | save, from anywhere | | `?` | this screen, from the editor too — h/l a column, / filters it down | @@ -157,9 +158,9 @@ do: `:w!`, `:space! `, `:import!`. | `:plan, :special` | the board, the agenda and the calendar, tiled ahead of what is open — S puts them up, and every view away again; 1 … 9 on the board 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 — gf on it opens the document | -| `:due <when\|->` | due date of the card — on the board, its back, or highlighted in the agenda: today, tm, fri, +3d, +2w, 10-31, 2026-10-31 — - clears | +| `:due <when\|->` | due date of the card in focus — on the board, its back, in the agenda, or the open document's: 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 | +| `:done` | move the card in focus into the done list, or back out to the inbox | | `:task, :step <text>` | add a step to the open card's checklist, below the cursor | | `:task!` | reword the step under the cursor | | `:label <name>` | put a label on the card, or take it off when it has it — each name keeps its colour everywhere | @@ -184,9 +185,10 @@ do: `:w!`, `:space! <name>`, `:import!`. | `:delete, :d, :rm [id]` | delete a node, and everything under it, into the trash (asks first) — u brings it back | | `:history, :hist` | the open document's earlier versions as diffs — j/k, Enter brings one back (gh) | | `:backlinks, :bl` | the documents that link to the open one — j/k, Enter opens one (gb) | +| `:cards` | the cards about the open document — its own, and the ones whose summary links here: j/k picks the one :due, :label, Space and H/L act on, Enter turns it over on its board (gc) | | `:source` | show an open markdown file as written, or rendered again | | `:outline, :toc` | the open document's headings to pick from — gO, in the editor too | -| `:info [outline\|history\|backlinks\|share]` | what is known about the open document: its headings, earlier versions, backlinks and sharing — Tab between them (gi) | +| `:info [outline\|history\|backlinks\|cards\|share]` | what is known about the open document: its headings, earlier versions, backlinks, cards and sharing — Tab between them (gi) | | `:search, :se [text]` | full-text search in this space (empty clears) | | `:search!` | full-text search across every space — Ctrl-P | | `:clear, :noh` | clear the search filter | diff --git a/docs/wiki/Files-and-Search.md b/docs/wiki/Files-and-Search.md index 25d6421..d797271 100644 --- a/docs/wiki/Files-and-Search.md +++ b/docs/wiki/Files-and-Search.md @@ -56,6 +56,10 @@ folder it is in. `Tab` or `Ctrl-N` / `Ctrl-P` walk them without leaving the line, and `Enter` opens the one lit. Afterwards `n` and `N` open the next and previous result beside the list, and `:noh` clears the search. +Cards are searched too, by their title, description and checklist steps. They +are listed after the documents, under `cards`, with the board and list each is +on. `Enter` on one turns it over on its board; `n` and `N` only light it up. + ![A search that found a line in a log file](../images/search.png) Each word you type matches the start of a word, so `inge` finds "ingest". All diff --git a/migrations/0027_card_links.sql b/migrations/0027_card_links.sql new file mode 100644 index 0000000..cd87f1b --- /dev/null +++ b/migrations/0027_card_links.sql @@ -0,0 +1,49 @@ +-- A document and its cards, seen from both ends. A card has always pointed at +-- one document, its notes, and the document could not see it back; a card's +-- summary could link to documents, and nothing kept those links. +-- +-- `card_links` is `links` for a card's summary: what its `/n/` paths and +-- `[[title]]`s lead to, rebuilt whenever the summary is written. Read the +-- other way round, it is the cards that mention a document, and the summaries +-- a rename or a move rewrites along with the documents. +-- +-- Nothing fills it for the cards already written; the server indexes them when +-- it starts and finds it empty (see `links::backfill`). +CREATE TABLE card_links ( + card_id INTEGER NOT NULL REFERENCES cards (id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK (kind IN ('path', 'wiki')), + target TEXT NOT NULL, + to_id INTEGER NOT NULL REFERENCES nodes (id) ON DELETE CASCADE, + PRIMARY KEY (card_id, kind, target) +) WITHOUT ROWID; + +CREATE INDEX idx_card_links_to ON card_links (to_id); + +-- Search finds a card the way it finds a document: by its title, its summary, +-- or a step on its checklist. The steps are JSON on the card, so the index +-- keeps its own copy of their text rather than reading the card's columns. +CREATE VIRTUAL TABLE cards_fts USING fts5 (title, body, steps, tokenize = 'unicode61'); + +CREATE TRIGGER cards_ai AFTER INSERT ON cards BEGIN + INSERT INTO cards_fts (rowid, title, body, steps) + VALUES (new.id, new.title, new.body, + (SELECT COALESCE(group_concat(json_extract(value, '$.text'), ' '), '') + FROM json_each(new.checklist))); +END; +CREATE TRIGGER cards_ad AFTER DELETE ON cards BEGIN + DELETE FROM cards_fts WHERE rowid = old.id; +END; +CREATE TRIGGER cards_au AFTER UPDATE OF title, body, checklist ON cards BEGIN + DELETE FROM cards_fts WHERE rowid = old.id; + INSERT INTO cards_fts (rowid, title, body, steps) + VALUES (new.id, new.title, new.body, + (SELECT COALESCE(group_concat(json_extract(value, '$.text'), ' '), '') + FROM json_each(new.checklist))); +END; + +-- The cards written before the triggers existed. +INSERT INTO cards_fts (rowid, title, body, steps) +SELECT id, title, body, + (SELECT COALESCE(group_concat(json_extract(value, '$.text'), ' '), '') + FROM json_each(cards.checklist)) +FROM cards; diff --git a/src/main.rs b/src/main.rs index 3fbad8b..ceb476f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -148,9 +148,9 @@ async fn main() -> Result<()> { // Before the first request, so no document reads as linked from nowhere. match routes::links::backfill(&state.db).await { Ok(0) => {} - Ok(1) => tracing::info!("indexed the links of 1 document"), - Ok(linking) => tracing::info!("indexed the links of {linking} documents"), - Err(e) => tracing::warn!("could not index the links between documents: {e:?}"), + Ok(1) => tracing::info!("indexed the links of 1 document or card"), + Ok(linking) => tracing::info!("indexed the links of {linking} documents and cards"), + Err(e) => tracing::warn!("could not index the links of documents and cards: {e:?}"), } // The text of files is read off the disk, which may take a while on a // first start, so the server does not wait for it. diff --git a/src/routes/boards.rs b/src/routes/boards.rs index 6837cfc..aaf7b1c 100644 --- a/src/routes/boards.rs +++ b/src/routes/boards.rs @@ -17,7 +17,7 @@ //! work is still open once its notes are gone. use axum::{ - extract::{Path, State}, + extract::{Path, Query, State}, Json, }; use std::collections::{BTreeMap, HashMap}; @@ -32,8 +32,9 @@ use crate::{ routes::{ links, manifest::ListMeta, - nodes::{DOCUMENT, SPACE}, + nodes::{self, DOCUMENT, SPACE}, repeat::Repeat, + trash, }, state::AppState, }; @@ -261,10 +262,54 @@ pub struct AgendaCard { pub space_id: i64, pub space_slug: String, pub list_name: String, - /// `inbox` for the first list, `doing` for any list between it and done. + /// `inbox` for the first list, `doing` for any list between it and the + /// last, `done` once finished. pub role: &'static str, } +/// What a card is read with, from `cards c`, as `row_to_card` reads it. +macro_rules! card_columns { + () => { + "c.id, c.list_id, c.title, c.body, c.node_id, c.due_at, c.done_at, + c.repeat, c.checklist, c.labels, c.position, c.created_at, c.updated_at, + (SELECT n.name FROM nodes n WHERE n.id = c.node_id) AS node_name" + }; +} + +/// Cards with where each stands, as `row_to_placed` reads them: on a board +/// that is shown, of a space that is not in the trash. The query goes on with +/// its own `WHERE`. +const PLACED: &str = concat!( + "WITH bounds AS ( + SELECT space_id, MIN(position) AS first, MAX(position) AS last + FROM board_lists GROUP BY space_id + ) + SELECT ", + card_columns!(), + ", + s.id AS space_id, s.slug AS space_slug, l.name AS list_name, + l.position = b.first AS is_first + FROM cards c + JOIN board_lists l ON l.id = c.list_id + JOIN bounds b ON b.space_id = l.space_id + JOIN nodes s ON s.id = l.space_id AND s.has_board <> 0 AND s.parent_id IS NULL" +); + +fn row_to_placed(row: &sqlx::sqlite::SqliteRow) -> AgendaCard { + let card = row_to_card(row); + AgendaCard { + role: match (card.done_at, row.get::<bool, _>("is_first")) { + (Some(_), _) => "done", + (None, true) => "inbox", + (None, false) => "doing", + }, + card, + space_id: row.get("space_id"), + space_slug: row.get("space_slug"), + list_name: row.get("list_name"), + } +} + /// Every unfinished card that is due within a week (or overdue), or that sits /// in a list between a board's inbox and its done list — work in hand. The /// last list of each board is done by definition, so nothing there is asked. @@ -303,46 +348,96 @@ pub async fn agenda(_: Session, State(state): State<AppState>) -> AppResult<Json /// The agenda's cards, due before `horizon` or in hand. Apart from the handler /// so the selection can be tested against a real schema. async fn agenda_cards(db: &SqlitePool, horizon: i64) -> AppResult<Vec<AgendaCard>> { - let rows = sqlx::query( - "WITH bounds AS ( - SELECT space_id, MIN(position) AS first, MAX(position) AS last - FROM board_lists GROUP BY space_id - ) - SELECT c.id, c.list_id, c.title, c.body, c.node_id, c.due_at, c.done_at, - c.repeat, c.checklist, c.labels, c.position, c.created_at, c.updated_at, - (SELECT n.name FROM nodes n WHERE n.id = c.node_id) AS node_name, - s.id AS space_id, s.slug AS space_slug, l.name AS list_name, - l.position = b.first AS is_first - FROM cards c - JOIN board_lists l ON l.id = c.list_id - JOIN bounds b ON b.space_id = l.space_id - JOIN nodes s ON s.id = l.space_id - WHERE s.has_board <> 0 AND s.parent_id IS NULL - AND c.done_at IS NULL + let rows = sqlx::query(&format!( + "{PLACED} + WHERE c.done_at IS NULL AND (l.position < b.last OR b.first = b.last) AND ((c.due_at IS NOT NULL AND c.due_at < ?1) OR (l.position > b.first)) - ORDER BY c.due_at IS NULL, c.due_at, s.position, l.position, c.position", - ) + ORDER BY c.due_at IS NULL, c.due_at, s.position, l.position, c.position" + )) .bind(horizon) .fetch_all(db) .await?; + Ok(rows.iter().map(row_to_placed).collect()) +} - Ok(rows +/// How many finished cards a document lists. A repeating card leaves one +/// behind every time it is done, and all of them would bury the open one. +const FINISHED_SHOWN: usize = 10; + +/// The cards about a node: the ones whose notes it is, and the ones whose +/// summary links to it. The open ones first, the soonest due first; then the +/// last few finished, the latest first. +pub(crate) async fn cards_of(db: &mut SqliteConnection, node: i64) -> AppResult<Vec<AgendaCard>> { + let rows = sqlx::query(&format!( + "{PLACED} + WHERE c.node_id = ?1 OR c.id IN (SELECT card_id FROM card_links WHERE to_id = ?1) + ORDER BY c.done_at IS NOT NULL, c.due_at IS NULL, c.due_at, c.done_at DESC, + s.position, l.position, c.position" + )) + .bind(node) + .fetch_all(&mut *db) + .await?; + let (open, done): (Vec<AgendaCard>, Vec<AgendaCard>) = rows .iter() - .map(|row| AgendaCard { - card: row_to_card(row), - space_id: row.get("space_id"), - space_slug: row.get("space_slug"), - list_name: row.get("list_name"), - role: if row.get::<bool, _>("is_first") { - "inbox" - } else { - "doing" - }, - }) + .map(row_to_placed) + .partition(|card| card.card.done_at.is_none()); + Ok(open + .into_iter() + .chain(done.into_iter().take(FINISHED_SHOWN)) .collect()) } +/// `GET /api/nodes/{id}/cards` — the cards about a document, to show beside +/// it and to work on without going to the board. +pub async fn node_cards( + _: Session, + State(state): State<AppState>, + Path(id): Path<i64>, +) -> AppResult<Json<Vec<AgendaCard>>> { + let mut conn = state.db.acquire().await?; + if trash::in_trash(&mut conn, id).await? { + return Err(AppError::NotFound); + } + cards_of(&mut conn, id).await.map(Json) +} + +#[derive(Deserialize)] +pub struct SearchQuery { + pub q: String, + /// Confine the search to one space's board. + #[serde(default)] + pub space: Option<i64>, +} + +/// How many cards a search lists. +const SEARCH_LIMIT: i64 = 50; + +/// `GET /api/cards?q=…` — the cards whose title, summary or a step of whose +/// checklist matches, the open ones first and the best match first in each. +pub async fn search( + _: Session, + State(state): State<AppState>, + Query(params): Query<SearchQuery>, +) -> AppResult<Json<Vec<AgendaCard>>> { + let Some(query) = nodes::fts_query(¶ms.q) else { + return Ok(Json(Vec::new())); + }; + let rows = sqlx::query(&format!( + "{PLACED} + JOIN cards_fts ON cards_fts.rowid = c.id + WHERE cards_fts MATCH ?1 AND (?2 IS NULL OR s.id = ?2) + ORDER BY c.done_at IS NOT NULL, cards_fts.rank + LIMIT ?3" + )) + .bind(query) + .bind(params.space) + .bind(SEARCH_LIMIT) + .fetch_all(&state.db) + .await?; + Ok(Json(rows.iter().map(row_to_placed).collect())) +} + /// `done_at` after a card moves from list `from` to list `to` on a board whose /// last list is `last`. Shuffling inside one list changes nothing; arriving in /// the last list finishes the card (keeping an earlier stamp), leaving it @@ -551,10 +646,11 @@ pub(crate) async fn restore( .fetch_one(&mut *db) .await?; for card in &list.cards { - sqlx::query( + let card_id: i64 = sqlx::query_scalar( "INSERT INTO cards (list_id, node_id, title, body, due_at, done_at, repeat, checklist, labels, position, created_at, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12) + RETURNING id", ) .bind(id) .bind(card.document.as_ref().and_then(|path| documents.get(path))) @@ -575,8 +671,9 @@ pub(crate) async fn restore( .bind(position) .bind(card.created_at) .bind(card.updated_at) - .execute(&mut *db) + .fetch_one(&mut *db) .await?; + links::index_card(db, card_id, &card.body).await?; position += 1; count += 1; } @@ -624,15 +721,15 @@ async fn load(state: &AppState, space_id: i64, space_name: String) -> AppResult< // One query for every card on the board rather than one per list: a board // is small, and the round trips are what would be felt. - let card_rows = sqlx::query( - "SELECT c.id, c.list_id, c.title, c.body, c.node_id, c.due_at, c.done_at, - c.repeat, c.checklist, c.labels, c.position, c.created_at, c.updated_at, - (SELECT n.name FROM nodes n WHERE n.id = c.node_id) AS node_name + let card_rows = sqlx::query(concat!( + "SELECT ", + card_columns!(), + " FROM cards c JOIN board_lists l ON l.id = c.list_id WHERE l.space_id = ?1 - ORDER BY c.position, c.id", - ) + ORDER BY c.position, c.id" + )) .bind(space_id) .fetch_all(&state.db) .await?; @@ -818,6 +915,7 @@ pub(crate) async fn insert_card( .fetch_one(&mut *tx) .await? .get("id"); + links::index_card(&mut tx, id, &input.body).await?; // A card made straight into the done list is finished as it lands. settle(&mut tx, space_id).await?; tx.commit().await?; @@ -926,6 +1024,9 @@ pub async fn update_card( .bind(card_id) .execute(&mut *tx) .await?; + if let Some(body) = &patch.body { + links::index_card(&mut tx, card_id, body).await?; + } // The move below sees the card as it is now, so a card given its rule // and finished in one request comes back by that rule. let current = Card { @@ -1155,6 +1256,7 @@ async fn come_back( .bind(now) .fetch_one(&mut *db) .await?; + links::index_card(db, id, &card.body).await?; sqlx::query("UPDATE cards SET repeat = NULL WHERE id = ?1") .bind(card.id) @@ -1175,12 +1277,12 @@ fn reopened(tasks: &[Task]) -> Vec<Task> { } async fn card(db: &SqlitePool, id: i64) -> AppResult<Card> { - let row = sqlx::query( - "SELECT c.id, c.list_id, c.title, c.body, c.node_id, c.due_at, c.done_at, - c.repeat, c.checklist, c.labels, c.position, c.created_at, c.updated_at, - (SELECT n.name FROM nodes n WHERE n.id = c.node_id) AS node_name - FROM cards c WHERE c.id = ?1", - ) + let row = sqlx::query(concat!( + "SELECT ", + card_columns!(), + " + FROM cards c WHERE c.id = ?1" + )) .bind(id) .fetch_optional(db) .await? diff --git a/src/routes/links.rs b/src/routes/links.rs index b3557a6..2652c52 100644 --- a/src/routes/links.rs +++ b/src/routes/links.rs @@ -245,23 +245,69 @@ async fn title_of(db: &mut SqliteConnection, id: i64) -> AppResult<String> { }) } -/// Record what `from` links to. Called by every write of a document's text, -/// after it, in the same transaction. -pub async fn index(db: &mut SqliteConnection, from: i64, body: &str) -> AppResult<()> { - let space = space_of(db, from).await?; - let before: HashMap<(Kind, String), i64> = - sqlx::query("SELECT kind, target, to_id FROM links WHERE from_id = ?1") - .bind(from) - .fetch_all(&mut *db) - .await? - .iter() - .map(|row| { - ( - (Kind::parse(row.get("kind")), row.get("target")), - row.get("to_id"), - ) - }) - .collect(); +/// Where a link is written: a document's text, or a card's summary. Both are +/// indexed, followed and rewritten alike; they are kept in tables of their own +/// only because a card is not a node. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +enum Source { + Node(i64), + Card(i64), +} + +impl Source { + /// The table its links are kept in, and the column that names it there. + fn table(self) -> (&'static str, &'static str, i64) { + match self { + Source::Node(id) => ("links", "from_id", id), + Source::Card(id) => ("card_links", "card_id", id), + } + } + + /// The space a `[[title]]` in it is looked for first: the document's, or + /// that of the board the card is on. + async fn space(self, db: &mut SqliteConnection) -> AppResult<Option<i64>> { + match self { + Source::Node(id) => space_of(db, id).await, + Source::Card(id) => Ok(sqlx::query_scalar( + "SELECT l.space_id FROM cards c JOIN board_lists l ON l.id = c.list_id + WHERE c.id = ?1", + ) + .bind(id) + .fetch_optional(&mut *db) + .await?), + } + } + + async fn body(self, db: &mut SqliteConnection) -> AppResult<String> { + let sql = match self { + Source::Node(_) => "SELECT body FROM nodes WHERE id = ?1", + Source::Card(_) => "SELECT body FROM cards WHERE id = ?1", + }; + Ok(sqlx::query_scalar(sql) + .bind(self.table().2) + .fetch_one(&mut *db) + .await?) + } +} + +/// Record what `source` links to, from its text as `body` now reads. +async fn record(db: &mut SqliteConnection, source: Source, body: &str) -> AppResult<()> { + let (table, column, id) = source.table(); + let space = source.space(db).await?; + let before: HashMap<(Kind, String), i64> = sqlx::query(&format!( + "SELECT kind, target, to_id FROM {table} WHERE {column} = ?1" + )) + .bind(id) + .fetch_all(&mut *db) + .await? + .iter() + .map(|row| { + ( + (Kind::parse(row.get("kind")), row.get("target")), + row.get("to_id"), + ) + }) + .collect(); let mut rows = Vec::new(); for (kind, target) in written(body) { @@ -274,31 +320,47 @@ pub async fn index(db: &mut SqliteConnection, from: i64, body: &str) -> AppResul _ => None, }, }; - if let Some(to) = to.filter(|&to| to != from) { + // A document linking to itself is not a link to it. + if let Some(to) = to.filter(|&to| source != Source::Node(to)) { rows.push((kind, target, to)); } } - sqlx::query("DELETE FROM links WHERE from_id = ?1") - .bind(from) + sqlx::query(&format!("DELETE FROM {table} WHERE {column} = ?1")) + .bind(id) .execute(&mut *db) .await?; for (kind, target, to) in rows { - sqlx::query("INSERT INTO links (from_id, kind, target, to_id) VALUES (?1, ?2, ?3, ?4)") - .bind(from) - .bind(kind.as_str()) - .bind(&target) - .bind(to) - .execute(&mut *db) - .await?; + sqlx::query(&format!( + "INSERT INTO {table} ({column}, kind, target, to_id) VALUES (?1, ?2, ?3, ?4)" + )) + .bind(id) + .bind(kind.as_str()) + .bind(&target) + .bind(to) + .execute(&mut *db) + .await?; } Ok(()) } +/// Record what `from` links to. Called by every write of a document's text, +/// after it, in the same transaction. +pub async fn index(db: &mut SqliteConnection, from: i64, body: &str) -> AppResult<()> { + record(db, Source::Node(from), body).await +} + +/// Record what a card's summary links to. Called by every write of it, after +/// it, in the same transaction. +pub async fn index_card(db: &mut SqliteConnection, card: i64, body: &str) -> AppResult<()> { + record(db, Source::Card(card), body).await +} + /// After `id` was created, renamed or moved, in the same transaction. Every /// link to it, or to anything under it, that no longer finds what it linked -/// to is rewritten to find it again; a link that found nothing until now and -/// names it now is recorded. Returns how many documents were rewritten. +/// to is rewritten to find it again, in a document or on a card; a link that +/// found nothing until now and names it now is recorded. Returns how many +/// texts were rewritten. pub async fn follow(db: &mut SqliteConnection, id: i64) -> AppResult<usize> { // Nothing in the trash is followed there: its links stay as they were // written, for whatever the restore brings it back as. @@ -311,16 +373,23 @@ pub async fn follow(db: &mut SqliteConnection, id: i64) -> AppResult<usize> { UNION ALL SELECT n.id FROM nodes n JOIN sub ON n.parent_id = sub.id ) - SELECT from_id, kind, target, to_id FROM links + SELECT 0 AS card, from_id AS id, kind, target, to_id FROM links + WHERE to_id IN (SELECT id FROM sub) + UNION ALL + SELECT 1, card_id, kind, target, to_id FROM card_links WHERE to_id IN (SELECT id FROM sub)", ) .bind(id) .fetch_all(&mut *db) .await?; - let mut linking: BTreeMap<i64, Vec<(Kind, String, i64)>> = BTreeMap::new(); + let mut linking: BTreeMap<Source, Vec<(Kind, String, i64)>> = BTreeMap::new(); for row in &rows { - linking.entry(row.get("from_id")).or_default().push(( + let source = match row.get::<bool, _>("card") { + true => Source::Card(row.get("id")), + false => Source::Node(row.get("id")), + }; + linking.entry(source).or_default().push(( Kind::parse(row.get("kind")), row.get("target"), row.get("to_id"), @@ -328,8 +397,8 @@ pub async fn follow(db: &mut SqliteConnection, id: i64) -> AppResult<usize> { } let mut rewritten = 0; - for (from, links) in linking { - let space = space_of(db, from).await?; + for (source, links) in linking { + let space = source.space(db).await?; let mut to = HashMap::new(); for (kind, target, node) in links { if resolve(db, kind, &target, space).await? == Some(node) { @@ -345,24 +414,33 @@ pub async fn follow(db: &mut SqliteConnection, id: i64) -> AppResult<usize> { continue; } - let body: String = sqlx::query_scalar("SELECT body FROM nodes WHERE id = ?1") - .bind(from) - .fetch_one(&mut *db) - .await?; + let body = source.body(db).await?; let body_now = rewrite(&body, &to); if body_now == body { continue; } - revisions::keep(db, from, &body_now, revisions::Reason::Edit).await?; // `updated_at` moves, so an editor open on the old text is told it // changed rather than saving the broken links back over it. - sqlx::query("UPDATE nodes SET body = ?1, updated_at = ?2 WHERE id = ?3") - .bind(&body_now) - .bind(crate::now()) - .bind(from) - .execute(&mut *db) - .await?; - index(db, from, &body_now).await?; + match source { + Source::Node(from) => { + revisions::keep(db, from, &body_now, revisions::Reason::Edit).await?; + sqlx::query("UPDATE nodes SET body = ?1, updated_at = ?2 WHERE id = ?3") + .bind(&body_now) + .bind(crate::now()) + .bind(from) + .execute(&mut *db) + .await?; + } + Source::Card(card) => { + sqlx::query("UPDATE cards SET body = ?1, updated_at = ?2 WHERE id = ?3") + .bind(&body_now) + .bind(crate::now()) + .bind(card) + .execute(&mut *db) + .await?; + } + } + record(db, source, &body_now).await?; rewritten += 1; } @@ -370,9 +448,9 @@ pub async fn follow(db: &mut SqliteConnection, id: i64) -> AppResult<usize> { Ok(rewritten) } -/// Index again the documents that may have been waiting for `id`: whose -/// `[[title]]` or path named nothing until it was called that, or put there. -/// A rough cut by text, as the index then decides what each link finds. +/// Index again the documents and cards that may have been waiting for `id`: +/// whose `[[title]]` or path named nothing until it was called that, or put +/// there. A rough cut by text, as the index then decides what each link finds. async fn claim(db: &mut SqliteConnection, id: i64) -> AppResult<()> { let row = sqlx::query("SELECT name, slug FROM nodes WHERE id = ?1") .bind(id) @@ -382,12 +460,17 @@ async fn claim(db: &mut SqliteConnection, id: i64) -> AppResult<()> { let name = row.get::<String, _>("name").trim().to_ascii_lowercase(); let slug: String = row.get("slug"); let path = path_text(db, id).await?; - let waiting: Vec<(i64, String)> = sqlx::query_as( - "SELECT id, body FROM nodes + let waiting: Vec<(bool, i64, String)> = sqlx::query_as( + "SELECT 0, id, body FROM nodes WHERE kind = 'document' AND id <> ?1 AND (instr(body, '/n/' || ?4) > 0 OR (instr(body, '[[') > 0 - AND ((?2 <> '' AND instr(lower(body), ?2) > 0) OR instr(body, ?3) > 0)))", + AND ((?2 <> '' AND instr(lower(body), ?2) > 0) OR instr(body, ?3) > 0))) + UNION ALL + SELECT 1, id, body FROM cards + WHERE instr(body, '/n/' || ?4) > 0 + OR (instr(body, '[[') > 0 + AND ((?2 <> '' AND instr(lower(body), ?2) > 0) OR instr(body, ?3) > 0))", ) .bind(id) .bind(&name) @@ -395,8 +478,13 @@ async fn claim(db: &mut SqliteConnection, id: i64) -> AppResult<()> { .bind(&path) .fetch_all(&mut *db) .await?; - for (from, body) in waiting { - index(db, from, &body).await?; + for (card, from, body) in waiting { + let source = if card { + Source::Card(from) + } else { + Source::Node(from) + }; + record(db, source, &body).await?; } Ok(()) } @@ -527,31 +615,52 @@ pub async fn backlinks( Ok(Json(found)) } -/// Index every document, when there is no link recorded at all: the ones -/// written before links were kept. Returns how many documents link anywhere. +/// Index every document, when there is no link recorded at all, and every +/// card, when no card's is: the ones written before links were kept. Returns +/// how many documents and cards link anywhere. pub async fn backfill(db: &SqlitePool) -> AppResult<usize> { - let any: bool = sqlx::query_scalar("SELECT EXISTS (SELECT 1 FROM links)") - .fetch_one(db) + let (documents, cards): (bool, bool) = sqlx::query_as( + "SELECT NOT EXISTS (SELECT 1 FROM links), NOT EXISTS (SELECT 1 FROM card_links)", + ) + .fetch_one(db) + .await?; + if !documents && !cards { + return Ok(0); + } + let mut written: Vec<(Source, String)> = Vec::new(); + if documents { + let rows: Vec<(i64, String)> = sqlx::query_as(&format!( + "WITH RECURSIVE {} SELECT id, body FROM nodes + WHERE kind = 'document' AND id IN (SELECT id FROM live) + AND (instr(body, '/n/') > 0 OR instr(body, '[[') > 0)", + nodes::LIVE + )) + .fetch_all(db) .await?; - if any { + written.extend(rows.into_iter().map(|(id, body)| (Source::Node(id), body))); + } + if cards { + let rows: Vec<(i64, String)> = sqlx::query_as( + "SELECT id, body FROM cards WHERE instr(body, '/n/') > 0 OR instr(body, '[[') > 0", + ) + .fetch_all(db) + .await?; + written.extend(rows.into_iter().map(|(id, body)| (Source::Card(id), body))); + } + if written.is_empty() { return Ok(0); } - let documents = sqlx::query(&format!( - "WITH RECURSIVE {} SELECT id, body FROM nodes - WHERE kind = 'document' AND id IN (SELECT id FROM live) - AND (instr(body, '/n/') > 0 OR instr(body, '[[') > 0)", - nodes::LIVE - )) - .fetch_all(db) - .await?; let mut tx = crate::db::begin_write(db).await?; - for row in &documents { - index(&mut tx, row.get("id"), row.get("body")).await?; + for (source, body) in &written { + record(&mut tx, *source, body).await?; } - let linking: i64 = sqlx::query_scalar("SELECT COUNT(DISTINCT from_id) FROM links") - .fetch_one(&mut *tx) - .await?; + let linking: i64 = sqlx::query_scalar( + "SELECT (SELECT COUNT(DISTINCT from_id) FROM links) + + (SELECT COUNT(DISTINCT card_id) FROM card_links)", + ) + .fetch_one(&mut *tx) + .await?; tx.commit().await?; Ok(linking as usize) } @@ -563,7 +672,10 @@ mod tests { use axum::extract::{Path, State}; use super::{rewrite, written, Kind}; - use crate::{routes::nodes, state::AppState}; + use crate::{ + routes::{boards, nodes}, + state::AppState, + }; #[test] fn what_counts_as_a_link() { @@ -804,4 +916,115 @@ mod tests { assert_eq!(targets(&state, linker).await[0].2, plan); assert_eq!(super::backfill(&state.db).await.unwrap(), 0); } + + async fn card(state: &AppState, space: i64, title: &str, node: Option<i64>, body: &str) -> i64 { + let board = boards::board(session(), State(state.clone()), Path(space)) + .await + .unwrap(); + let input = serde_json::from_value(serde_json::json!({ + "title": title, "body": body, "node_id": node, + })) + .unwrap(); + boards::create_card( + session(), + State(state.clone()), + Path(board.lists[0].id), + axum::Json(input), + ) + .await + .unwrap() + .id + } + + async fn cards_about(state: &AppState, node: i64) -> Vec<i64> { + boards::node_cards(session(), State(state.clone()), Path(node)) + .await + .unwrap() + .iter() + .map(|placed| placed.card.id) + .collect() + } + + /// A document lists the cards about it — its own, and the ones whose + /// summary links to it — and a rename rewrites a card's links as it does + /// a document's. + #[tokio::test] + async fn cards_link_to_documents_as_documents_do() { + let state = crate::routes::tests::state_in(".".into()).await; + let work = space(&state, "work").await; + let plan = create(&state, work, "document", "Plan", "").await; + let notes = card(&state, work, "write the plan", Some(plan), "").await; + let mentions = card( + &state, + work, + "ask about it", + None, + "see [[Plan]] and [p](/n/work/plan)", + ) + .await; + let waiting = card(&state, work, "later", None, "[[Roadmap]] first").await; + assert_eq!(cards_about(&state, plan).await, [notes, mentions]); + + rename(&state, plan, "Roadmap").await; + let summary: String = sqlx::query_scalar("SELECT body FROM cards WHERE id = ?1") + .bind(mentions) + .fetch_one(&state.db) + .await + .unwrap(); + assert_eq!(summary, "see [[Roadmap]] and [p](/n/work/roadmap)"); + // The card that named it before it was called that now finds it. + assert_eq!(cards_about(&state, plan).await, [notes, mentions, waiting]); + } + + /// Search finds a card by its title, its summary and its checklist. + #[tokio::test] + async fn search_finds_cards() { + let state = crate::routes::tests::state_in(".".into()).await; + let work = space(&state, "work").await; + let id = card( + &state, + work, + "quarterly report", + None, + "numbers from finance", + ) + .await; + let input = serde_json::from_value(serde_json::json!({ + "checklist": [{ "text": "gather receipts" }], + })) + .unwrap(); + let _updated = + boards::update_card(session(), State(state.clone()), Path(id), axum::Json(input)) + .await + .unwrap(); + + for q in ["quarterly", "finance", "receipts", "recei"] { + let found = boards::search( + session(), + State(state.clone()), + axum::extract::Query(boards::SearchQuery { + q: q.into(), + space: None, + }), + ) + .await + .unwrap(); + assert_eq!( + found.iter().map(|c| c.card.id).collect::<Vec<_>>(), + [id], + "{q}" + ); + } + let elsewhere = boards::search( + session(), + State(state.clone()), + axum::extract::Query(boards::SearchQuery { + q: "finance".into(), + space: Some(space(&state, "uni").await), + }), + ) + .await + .unwrap(); + assert!(elsewhere.is_empty()); + } } diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 88dec02..6ff6b76 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -81,6 +81,7 @@ pub fn api_router(max_upload: usize) -> Router<AppState> { "/cards/{id}", put(boards::update_card).delete(boards::remove_card), ) + .route("/cards", get(boards::search)) .route("/cards/{id}/move", post(boards::move_card)) .route("/nodes", get(nodes::list).post(nodes::create)) .route("/resolve", get(nodes::resolve)) @@ -89,6 +90,7 @@ pub fn api_router(max_upload: usize) -> Router<AppState> { .route("/nodes/{id}", delete(nodes::remove)) .route("/nodes/{id}/revisions", get(revisions::list)) .route("/nodes/{id}/backlinks", get(links::backlinks)) + .route("/nodes/{id}/cards", get(boards::node_cards)) .route("/nodes/{id}/templates", get(diary::templates)) .route("/nodes/{id}/revisions/{rev}", get(revisions::get)) .route("/nodes/move", post(nodes::move_many)) diff --git a/src/routes/nodes.rs b/src/routes/nodes.rs index 3f2fcc6..ab6b26d 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::{diary, links, media, revisions, trash}, + routes::{boards, diary, links, media, revisions, trash}, state::AppState, }; @@ -112,6 +112,9 @@ pub struct Node { pub path: Vec<Crumb>, /// How many documents link here. pub backlinks: i64, + /// The cards about it: the ones whose notes it is, and the ones whose + /// summary links here. + pub cards: Vec<boards::AgendaCard>, /// Where each `[[title]]` in the body leads, as an /n/... path. A title /// that leads nowhere is not in it. pub wikilinks: BTreeMap<String, String>, @@ -252,7 +255,7 @@ pub(crate) fn excerpt(head: &str) -> String { /// Turn free text into an FTS5 prefix query, discarding anything that could be /// read as FTS syntax. -fn fts_query(raw: &str) -> Option<String> { +pub(crate) fn fts_query(raw: &str) -> Option<String> { let terms: Vec<String> = raw .split_whitespace() .map(|term| term.replace(|c: char| !c.is_alphanumeric(), " ")) @@ -1146,11 +1149,12 @@ pub async fn load(state: &AppState, id: i64) -> AppResult<Node> { let body: String = row.get("body"); let summary = row_to_summary(&row, &body.chars().take(HEAD_CHARS).collect::<String>()); - let (backlinks, wikilinks) = { + let (backlinks, wikilinks, cards) = { let mut conn = state.db.acquire().await?; ( links::linked_from(&mut conn, id).await?, links::wikilinks(&mut conn, id, &body).await?, + boards::cards_of(&mut conn, id).await?, ) }; @@ -1174,6 +1178,7 @@ pub async fn load(state: &AppState, id: i64) -> AppResult<Node> { file: summary.file, path: path_of(&state.db, id).await?, backlinks, + cards, wikilinks, }) } diff --git a/web/src/lib/Agenda.svelte b/web/src/lib/Agenda.svelte index ace874b..892c688 100644 --- a/web/src/lib/Agenda.svelte +++ b/web/src/lib/Agenda.svelte @@ -77,7 +77,7 @@ </button> {#if isTouchDevice} <!-- Space, for a thumb. --> - <button class="finish" aria-label="done: {card.title}" onclick={() => workspace.guard(() => workspace.finishCard(card))}> + <button class="finish" aria-label="done: {card.title}" onclick={() => workspace.guard(() => workspace.toggleDone(card))}> <Icon name="check" size={18} /> </button> {/if} diff --git a/web/src/lib/CommandLine.svelte b/web/src/lib/CommandLine.svelte index 551f939..2e09165 100644 --- a/web/src/lib/CommandLine.svelte +++ b/web/src/lib/CommandLine.svelte @@ -129,7 +129,7 @@ const query = line.slice(1) await workspace.guard(async () => { if (query && workspace.query !== query) await workspace.search(query, global) - if (query && workspace.selected) await workspace.openSelected() + if (query && (workspace.selected || workspace.foundCard)) await workspace.openSelected() }) hooks.focusList() return diff --git a/web/src/lib/DocumentPane.svelte b/web/src/lib/DocumentPane.svelte index 0b878d2..66ab6b6 100644 --- a/web/src/lib/DocumentPane.svelte +++ b/web/src/lib/DocumentPane.svelte @@ -5,6 +5,7 @@ import { workspace, type Tile } from './store.svelte' import Icon from './Icon.svelte' import { + formatDay, formatStamp, formatWeekday, headingsIn, @@ -26,6 +27,9 @@ const words = $derived( (tile.editing ? tile.draft.body : doc.body).split(/\s+/).filter(Boolean).length, ) + /** What is still to be done about it, and when the first of that is due. */ + const tasks = $derived(doc.cards.filter((card) => card.done_at === null)) + const due = $derived(tasks.reduce<number | null>((first, card) => (card.due_at !== null && (first === null || card.due_at < first) ? card.due_at : first), null)) let reader = $state<HTMLElement>() let article = $state<HTMLElement>() let title = $state<HTMLInputElement>() @@ -162,6 +166,18 @@ <span class="field public"><span class="ring"></span>public</span> {/if} </button> + <!-- The work about it, in a line: what is still to do here — gc lists it. --> + {#if tasks.length > 0} + <button class="meta tasks" title="cards (gc)" onclick={() => openInfo('cards')}> + <span class="field"> + <span class="caption">{tasks.length === 1 ? 'card' : 'cards'}</span> + {#if tasks.length === 1}<span class="what">{tasks[0].title}</span> · {tasks[0].list_name}{:else}{tasks.length} open{/if} + </span> + {#if due !== null} + <span class="field" class:late={due < Date.now() / 1000}><span class="caption">due</span>{formatWeekday(due)} {formatDay(due)}</span> + {/if} + </button> + {/if} </header> {#if tile.editing} @@ -277,6 +293,8 @@ .meta:hover .caption, .meta:focus-visible .caption { color: var(--light); } .caption { margin-right: 8px; color: var(--muted); letter-spacing: 0.18em; text-transform: uppercase; } .public { display: flex; align-items: center; gap: 6px; color: var(--light); } + .tasks .what { color: var(--light); } + .tasks .late { color: var(--alarm); } .ring { width: 7px; height: 7px; flex: none; border: 1.5px solid var(--light); border-radius: 999px; } .title { diff --git a/web/src/lib/Info.svelte b/web/src/lib/Info.svelte index f1e8022..6836494 100644 --- a/web/src/lib/Info.svelte +++ b/web/src/lib/Info.svelte @@ -1,5 +1,5 @@ <script lang="ts" module> - import type { Backlink, NodeSummary, RevisionSummary } from './api' + import type { AgendaCard, Backlink, NodeSummary, RevisionSummary } from './api' import { workspace, type InfoSection } from './store.svelte' import { formatStamp } from './util' @@ -7,9 +7,9 @@ * What the keys in actions.ts do here too, so a key and a click are the * same thing. The info sheet holds what is known about the open document * besides its text: its headings, its earlier versions, what links to it, - * and whether it is shared. + * the cards about it, and whether it is shared. */ - export const infoSections: InfoSection[] = ['outline', 'history', 'backlinks', 'share'] + export const infoSections: InfoSection[] = ['outline', 'history', 'backlinks', 'cards', 'share'] /** Tab and Shift-Tab: the next part along, round the ends. */ export function turnInfo(by: number) { @@ -44,6 +44,12 @@ return workspace.guard(() => workspace.openPath(link.path)) } + /** Enter on a card: the sheet away, and the card turned over on its board. */ + export function turnCard(card: AgendaCard) { + workspace.closeSheet() + return workspace.guard(() => workspace.turnTo(card)) + } + // Sharing is a key away; taking a link back from its readers asks first. export async function toggleSharing(node: NodeSummary | null | undefined) { if (!node) return workspace.say('nothing selected', 'error') @@ -66,14 +72,28 @@ restore: 'replaced by a restore', } const reason = (summary: RevisionSummary) => REASONS[summary.reason] ?? summary.reason + + /** Where a card stands, as one word: done, or the list it is on. */ + const standing = (card: AgendaCard) => (card.done_at !== null ? 'done' : card.list_name) </script> <script lang="ts"> import Sheet from './Sheet.svelte' import { diffLines, folded } from './diff' - import { relative } from './util' + import { formatDay, relative } from './util' const doc = $derived(workspace.open) + + /** `work · doing · due 2026-10-23 · mentions it`: a card's line under its title. */ + const where = (card: AgendaCard) => + [ + card.space_slug, + standing(card), + card.due_at !== null && card.done_at === null ? `due ${formatDay(card.due_at)}` : '', + card.node_id !== doc?.id ? 'mentions it' : '', + ] + .filter(Boolean) + .join(' · ') const section = $derived(workspace.infoSection) // The shallowest level in the document is the left edge, so a text that @@ -95,11 +115,18 @@ ['outline', `outline ${workspace.outline.length}`], ['history', `history ${workspace.history.length}`], ['backlinks', `linked from ${workspace.backlinks.length}`], + ['cards', `cards ${doc?.cards.filter((card) => card.done_at === null).length ?? 0}`], ['share', doc?.shared ? 'shared' : 'private'], ]) const cursor = $derived( - { outline: workspace.outlineCursor, history: workspace.historyCursor, backlinks: workspace.backlinksCursor, share: undefined }[section], + { + outline: workspace.outlineCursor, + history: workspace.historyCursor, + backlinks: workspace.backlinksCursor, + cards: workspace.cardsCursor, + share: undefined, + }[section], ) </script> @@ -186,6 +213,25 @@ <li class="muted">nothing links here yet — a link is [text](/n/…) or [[title]].</li> {/each} </ul> + {:else if section === 'cards'} + <ul class="items"> + {#each doc?.cards ?? [] as card, index (card.id)} + <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions --> + <li + class="item" + class:at={index === workspace.cardsCursor} + class:done={card.done_at !== null} + data-index={index} + onpointerenter={() => (workspace.cardsCursor = index)} + onclick={() => turnCard(card)} + > + <span class="name">{card.title}</span> + <span class="muted">{where(card)}</span> + </li> + {:else} + <li class="muted">no card about this document — c makes one, and a card's [[title]] counts too.</li> + {/each} + </ul> {:else} <div class="share"> {#if sharePath} @@ -236,6 +282,8 @@ .row.added { background: color-mix(in oklab, var(--light) 14%, transparent); } .row.fold { color: var(--muted); } + .done .name { color: var(--muted); text-decoration: line-through; } + .context { font-size: 12px; color: var(--subtle); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .share { display: flex; flex-direction: column; gap: 12px; font-size: 12px; } diff --git a/web/src/lib/Sidebar.svelte b/web/src/lib/Sidebar.svelte index ab0cd5d..763236a 100644 --- a/web/src/lib/Sidebar.svelte +++ b/web/src/lib/Sidebar.svelte @@ -77,7 +77,10 @@ <div class="head label rule"> <span class="marker"></span> <span class="name">{workspace.space?.slug ?? 'diary'}</span> - <span class="count">{workspace.nodes.length} {workspace.nodes.length === 1 ? 'item' : 'items'}</span> + <span class="count"> + {workspace.nodes.length} {workspace.nodes.length === 1 ? 'item' : 'items'}{#if workspace.foundCards.length > 0} + · {workspace.foundCards.length} {workspace.foundCards.length === 1 ? 'card' : 'cards'}{/if} + </span> </div> {#if workspace.query} @@ -144,7 +147,22 @@ </button> {/if} </li> - {:else} + {/each} + <!-- The cards a search found, after the nodes: Enter turns one over on its board. --> + {#if workspace.foundCards.length > 0} + <li class="daybreak label rule"><span>cards</span></li> + {/if} + {#each workspace.foundCards as card, at (card.id)} + {@const index = workspace.nodes.length + at} + <li class="item"> + <button class="row card" class:active={index === workspace.cursor} class:done={card.done_at !== null} data-index={index} onclick={() => pick(index)}> + <span class="dot"></span> + <span class="title">{card.title}<span class="where">{card.space_slug}/{card.list_name}</span></span> + <span class="aside">{card.done_at !== null ? 'done' : card.due_at !== null ? formatDay(card.due_at) : 'card'}</span> + </button> + </li> + {/each} + {#if workspace.rows === 0} <li class="empty muted"> {#if workspace.query} no matches @@ -152,7 +170,7 @@ nothing here yet — <span class="kbd">o</span> writes, <span class="kbd">⇧ O</span> makes a folder, files dropped here land here {/if} </li> - {/each} + {/if} </ul> </div> @@ -246,6 +264,9 @@ } .row.open .dot { background: var(--light); } .row.open.active .dot { background: var(--ink); } + /* A card is told from a node by its square. */ + .row.card .dot { border: 1px solid currentColor; border-radius: 1px; } + .row.card.done .title { color: var(--muted); text-decoration: line-through; } .title { flex: 1; diff --git a/web/src/lib/actions.ts b/web/src/lib/actions.ts index 7cdee35..220675f 100644 --- a/web/src/lib/actions.ts +++ b/web/src/lib/actions.ts @@ -34,7 +34,7 @@ const spaceIndex = (key: string) => { const reading = () => !!workspace.open && !workspace.editing && workspace.pane === 'document' /** A cursor down a sheet's list, kept on it. */ -function step(cursor: 'outlineCursor' | 'mediaCursor' | 'trashCursor' | 'backlinksCursor', length: number, by: number) { +function step(cursor: 'outlineCursor' | 'mediaCursor' | 'trashCursor' | 'backlinksCursor' | 'cardsCursor', length: number, by: number) { workspace[cursor] = Math.min(Math.max(workspace[cursor] + by, 0), Math.max(length - 1, 0)) } @@ -209,6 +209,33 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = { }, }, + /** + * Its cards: j/k picks the one the card verbs act on — Space, H/L, `:due`, + * `:label` — and Enter turns it over on its board. + */ + cards: { + down: () => step('cardsCursor', workspace.open?.cards.length ?? 0, 1), + up: () => step('cardsCursor', workspace.open?.cards.length ?? 0, -1), + bottom: () => step('cardsCursor', workspace.open?.cards.length ?? 0, Infinity), + turn: () => { + const card = workspace.open?.cards[workspace.cardsCursor] + if (!card) return + workspace.closeSheet() + guard(() => workspace.turnTo(card)) + }, + done: () => guard(() => workspace.toggleDone()), + sendLeft: () => guard(() => workspace.sendCard(-1)), + sendRight: () => guard(() => workspace.sendCard(1)), + due: () => { + if (workspace.focusedCard) line(':due ') + }, + new: () => line(':card! '), + label: () => { + if (workspace.focusedCard) line(':label ') + }, + line: () => line(':'), + }, + /** Its history: j/k through the versions, Enter brings the one shown back. */ history: { down: () => moveHistory(1), @@ -307,7 +334,7 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = { down: () => void (reading() ? hooks.scrollReader(1) : workspace.move(1)), up: () => void (reading() ? hooks.scrollReader(-1) : workspace.move(-1)), top: () => void (reading() ? hooks.scrollReader('top') : (workspace.cursor = 0)), - bottom: () => void (reading() ? hooks.scrollReader('bottom') : (workspace.cursor = Math.max(workspace.nodes.length - 1, 0))), + bottom: () => void (reading() ? hooks.scrollReader('bottom') : (workspace.cursor = Math.max(workspace.rows - 1, 0))), open: (key) => { const enter = key === 'Enter' || key === 'Shift-Enter' if (enter && reading() && hooks.fileAction()) return @@ -341,6 +368,7 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = { outline: () => void runCommand('outline'), history: () => void runCommand('history'), backlinks: () => void runCommand('backlinks'), + cards: () => void runCommand('cards'), inTab: () => openInTab(), // `f` already fills the screen, so the hints vimium puts on `f` live on // its other key. @@ -357,7 +385,8 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = { drop: () => guard(() => workspace.drop()), new: () => void runCommand('new'), // Capture: a thought goes on this space's inbox without leaving here. - capture: () => line(':card '), + // Reading a document, the card is about it. + capture: () => line(workspace.focus === 'node' && workspace.open?.kind === 'document' ? ':card! ' : ':card '), // The name has to come from somewhere, and the command line is where // names are typed — this only saves typing `:mkdir `. mkdir: () => line(':mkdir '), @@ -445,16 +474,13 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = { // Beside the agenda, so `q` on it comes back here. else if (past) guard(() => workspace.openPath(past.path)) }, - finish: () => { - const card = workspace.agendaCards[workspace.agendaCursor] - if (card) guard(() => workspace.finishCard(card)) - }, + finish: () => guard(() => workspace.toggleDone()), due: () => { if (workspace.agendaCards[workspace.agendaCursor]) line(':due ') }, line: () => line(':'), - sendLeft: () => sendAgendaCard(-1), - sendRight: () => sendAgendaCard(1), + sendLeft: () => guard(() => workspace.sendCard(-1)), + sendRight: () => guard(() => workspace.sendCard(1)), today: () => void runCommand('today'), }, @@ -520,8 +546,3 @@ function goToSpace(index: number) { workspace.closeSheet() guard(() => workspace.enterSpace(entry.space.id)) } - -function sendAgendaCard(by: 1 | -1) { - const card = workspace.agendaCards[workspace.agendaCursor] - if (card) guard(() => workspace.sendAgendaCard(card, by)) -} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 46cb8d1..b9cb8ca 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -53,6 +53,8 @@ export interface WorkspaceNode extends NodeSummary { path: Crumb[] /** How many documents link here. */ backlinks: number + /** The cards about it — its own, and those whose summary links here — open ones first. */ + cards: AgendaCard[] /** Where each `[[title]]` in the body leads, as an /n/ path; a title that leads nowhere is missing. */ wikilinks: Record<string, string> } @@ -191,12 +193,16 @@ export interface Moved extends Card { again: Card | null } -/** An open card as the agenda lists it, with the board it lives on. */ +/** + * A card with the board it lives on: as the agenda lists it, as a document + * lists the cards about it, and as a search finds it. + */ export interface AgendaCard extends Card { space_id: number space_slug: string list_name: string - role: 'inbox' | 'doing' + /** The agenda only ever has the first two. */ + role: 'inbox' | 'doing' | 'done' } export interface Agenda { @@ -456,6 +462,11 @@ export const api = { }, getNode: (id: number) => request<WorkspaceNode>(`/nodes/${id}`), + /** The cards about a document, as `getNode` has them. */ + nodeCards: (id: number) => request<AgendaCard[]>(`/nodes/${id}/cards`), + /** The cards whose title, summary or steps match — in one space's board with `space`. */ + searchCards: (q: string, space?: number) => + request<AgendaCard[]>(`/cards?${new URLSearchParams(space === undefined ? { q } : { q, space: String(space) })}`), /** `work/einarbeiten/01-was-ist-eebus` — the node behind a `/n/...` address. */ resolvePath: (path: string) => request<WorkspaceNode>(`/resolve?path=${encodeURIComponent(path)}`), diff --git a/web/src/lib/commands.ts b/web/src/lib/commands.ts index ba3cf18..66258b3 100644 --- a/web/src/lib/commands.ts +++ b/web/src/lib/commands.ts @@ -78,7 +78,8 @@ const closeFocused = () => { /** * The info sheet over the open document, at `section` or where it was last * left. Every part is read now, so each one's count is in its tab: the - * headings from the pane, the versions and backlinks from the server. + * headings from the pane, the versions and backlinks from the server, and + * the cards with the document. */ export async function openInfo(section?: InfoSection) { if (!requireOpen()) return @@ -90,6 +91,8 @@ export async function openInfo(section?: InfoSection) { // of the same text at once would race. await workspace.loadHistory() await workspace.loadBacklinks() + // Picked afresh each time, from the first: the open card leads. + workspace.cardsCursor = 0 if (section) workspace.infoSection = section workspace.openSheet('info') }) @@ -97,8 +100,6 @@ export async function openInfo(section?: InfoSection) { /** The board's cursor, or the card turned over beside it, has the keys. */ const onBoard = () => workspace.focus === 'board' || workspace.focus === 'card' -/** A card has the keys: on the board, its back, or highlighted in the agenda. */ -const cardFocused = () => onBoard() || workspace.focus === 'agenda' const requireOpen = () => { if (!workspace.open) { @@ -162,8 +163,8 @@ const describeTile = (index: number) => { /** Move the cursor and follow it, so `:next` reads as well as `j` then `Enter`. */ const jumpTo = async (index: number) => { - if (workspace.nodes.length === 0) return workspace.say('nothing here', 'error') - workspace.cursor = Math.min(Math.max(index, 0), workspace.nodes.length - 1) + if (workspace.rows === 0) return workspace.say('nothing here', 'error') + workspace.cursor = Math.min(Math.max(index, 0), workspace.rows - 1) await workspace.guard(() => workspace.openSelected()) } @@ -509,19 +510,16 @@ export const commands: CommandSpec[] = [ name: 'due', args: '<when|->', group: 'board', - help: 'due date of the card — on the board, its back, or highlighted in the agenda: today, tm, fri, +3d, +2w, 10-31, 2026-10-31 — - clears', + help: "due date of the card in focus — on the board, its back, in the agenda, or the open document's: today, tm, fri, +3d, +2w, 10-31, 2026-10-31 — - clears", complete: () => ['today', 'tm', 'fri', '+3d'], run: async (arg) => { - const agenda = workspace.focus === 'agenda' - if (!cardFocused()) return workspace.say('no card in focus — :board', 'error') - const set = (due_at: number | null) => - agenda ? workspace.patchAgendaCard({ due_at }) : workspace.patchCard({ due_at }) const target = arg.trim() - if (target === '-' || target === '') return workspace.guard(() => set(null)) + if (target === '-' || target === '') return workspace.guard(() => workspace.patchCard({ due_at: null })) const at = parseDue(target) if (at === null) return workspace.say(`not a date: ${target} — try fri, +3d or 2026-09-30`, 'error') - await workspace.guard(() => set(at)) - workspace.say(`due ${formatDay(at)}`) + await workspace.guard(async () => { + if (await workspace.patchCard({ due_at: at })) workspace.say(`due ${formatDay(at)}`) + }) }, }, { @@ -531,12 +529,10 @@ export const commands: CommandSpec[] = [ help: 'once the card is done, a copy comes back in the inbox: daily, weekly, monthly, yearly, +3d, +2w, +6m — - stops it', complete: () => ['daily', 'weekly', 'monthly', 'yearly', '+2w', '-'], run: async (arg) => { - const agenda = workspace.focus === 'agenda' - if (!cardFocused()) return workspace.say('no card in focus — :board', 'error') const rule = arg.trim() const repeat = rule === '-' || rule === '' ? null : rule await workspace.guard(async () => { - const card = await (agenda ? workspace.patchAgendaCard({ repeat }) : workspace.patchCard({ repeat })) + const card = await workspace.patchCard({ repeat }) // The server's spelling of the rule: `+14d` is kept as `+2w`. if (card) workspace.say(card.repeat === null ? 'repeats no more' : `repeats ${card.repeat} once done`) }) @@ -545,15 +541,8 @@ export const commands: CommandSpec[] = [ { name: 'done', group: 'board', - help: 'move the selected card into the done list, or back out to the inbox', - run: () => { - if (workspace.focus === 'agenda') { - const card = workspace.agendaCards[workspace.agendaCursor] - return card ? workspace.guard(() => workspace.finishCard(card)) : workspace.say('no card highlighted', 'error') - } - if (!onBoard()) return workspace.say('no card in focus — :board', 'error') - return workspace.guard(() => workspace.toggleDone()) - }, + help: 'move the card in focus into the done list, or back out to the inbox', + run: () => workspace.guard(() => workspace.toggleDone()), }, { name: 'scratch', @@ -603,8 +592,8 @@ export const commands: CommandSpec[] = [ help: 'put a label on the card, or take it off when it has it — each name keeps its colour everywhere', complete: () => workspace.boardLabels, run: async (arg) => { - if (!onBoard()) return workspace.say('no card in focus — :board', 'error') - if (!arg.trim()) return workspace.say(workspace.card?.labels.join(' · ') || 'no labels — :label <name> adds one') + const card = workspace.focusedCard + if (!arg.trim() && card) return workspace.say(card.labels.join(' · ') || 'no labels — :label <name> adds one') await workspace.guard(() => workspace.toggleLabel(arg)) }, }, @@ -840,7 +829,7 @@ export const commands: CommandSpec[] = [ name: 'last', group: 'documents', help: 'open the last node in the list', - run: () => jumpTo(workspace.nodes.length - 1), + run: () => jumpTo(workspace.rows - 1), }, { name: 'random', @@ -953,6 +942,12 @@ export const commands: CommandSpec[] = [ await openInfo('backlinks') }, }, + { + name: 'cards', + group: 'documents', + help: 'the cards about the open document — its own, and the ones whose summary links here: j/k picks the one :due, :label, Space and H/L act on, Enter turns it over on its board (gc)', + run: () => openInfo('cards'), + }, { name: 'trash', args: '[purge]', @@ -1126,13 +1121,13 @@ export const commands: CommandSpec[] = [ }, { name: 'info', - args: '[outline|history|backlinks|share]', + args: '[outline|history|backlinks|cards|share]', group: 'documents', - help: 'what is known about the open document: its headings, earlier versions, backlinks and sharing — Tab between them (gi)', + help: 'what is known about the open document: its headings, earlier versions, backlinks, cards and sharing — Tab between them (gi)', complete: () => infoSections, run: (arg) => { const section = arg.trim() - if (section && !infoSections.includes(section as InfoSection)) return workspace.say('usage: :info [outline|history|backlinks|share]', 'error') + if (section && !infoSections.includes(section as InfoSection)) return workspace.say('usage: :info [outline|history|backlinks|cards|share]', 'error') return openInfo((section || undefined) as InfoSection | undefined) }, }, diff --git a/web/src/lib/keymap.ts b/web/src/lib/keymap.ts index aef13f8..7ff7447 100644 --- a/web/src/lib/keymap.ts +++ b/web/src/lib/keymap.ts @@ -23,6 +23,7 @@ export type Context = | 'media' | 'trash' | 'backlinks' + | 'cards' | 'history' | 'share' | 'card' @@ -89,7 +90,7 @@ export const bindings = [ { context: 'tree', keys: ['zh', 'zl', 'zH', 'zL'], action: 'sideways', label: 'zl / zh · zL / zH', help: 'the wide table or code block at the top of the view, sideways — a little, or half its width' }, { context: 'tree', keys: [']]'], action: 'nextHeading', label: ']] / [[', help: 'next / previous heading of the document — in the editor too' }, { context: 'tree', keys: ['[['], action: 'previousHeading' }, - { context: 'tree', keys: ['gi'], action: 'info', help: "the info sheet: the open document's headings, earlier versions, backlinks and sharing — Tab between them" }, + { context: 'tree', keys: ['gi'], action: 'info', help: "the info sheet: the open document's headings, earlier versions, backlinks, cards and sharing — Tab between them" }, { context: 'tree', keys: ['gO'], action: 'outline', help: 'the headings as a list: j/k, Enter jumps there' }, { context: 'tree', keys: ['F'], action: 'hints', help: 'hints over the links and buttons in view — type one to follow it' }, { context: 'tree', label: 'gf / gx', help: 'in the editor: follow the link under the cursor / open it in a tab' }, @@ -112,7 +113,7 @@ export const bindings = [ { context: 'tree', keys: [']d'], action: 'nextEntry' }, { context: 'global', keys: ['Ctrl-p'], action: 'find', label: 'Ctrl-P', help: 'find anything, in every space — Enter opens the result lit beside them, n goes on' }, { context: 'global', keys: ['b'], action: 'board', help: "the board, in a tile: this space's, or the last one shown — 1 … 9 there shows another's" }, - { context: 'tree', keys: ['c'], action: 'capture', help: "capture a card into this space's inbox, without leaving here" }, + { context: 'tree', keys: ['c'], action: 'capture', help: "capture a card into this space's inbox, without leaving here — reading a document, a card about it" }, { context: 'tree', keys: ['q', 'Escape'], action: 'close', label: 'Esc · q', help: 'close the document, and its tile when there are several' }, { context: 'tree', keys: ['/'], action: 'search', help: 'full-text search, inside the current space and its text files — Tab or Ctrl-N/P walk the results, Enter opens one' }, { context: 'tree', keys: ['n'], action: 'next', label: 'n / N', help: 'next / previous search result, opened beside the results — :noh clears the search' }, @@ -123,6 +124,7 @@ export const bindings = [ { context: 'tree', keys: ['u'], action: 'undo', help: 'undo the last delete: it comes back out of the trash — :trash has the rest' }, { context: 'tree', keys: ['gh'], action: 'history', help: "the open document's earlier versions, as diffs — Enter brings one back" }, { context: 'tree', keys: ['gb'], action: 'backlinks', help: 'the documents that link to the open one — [[title]] links by title, [[ completes one while writing' }, + { context: 'tree', keys: ['gc'], action: 'cards', help: 'the cards about the open document — j/k picks the one :due, :label, Space and H/L act on, Enter turns it over on its board' }, { context: 'global', keys: [':'], action: 'line', help: 'command line — Tab / Shift-Tab cycle the matches, then the argument; ↑ ↓ recall what ran before' }, { context: 'always', keys: ['Ctrl-s', 'Meta-s'], action: 'save', typing: true, label: 'Ctrl-S', help: 'save, from anywhere' }, { context: 'global', keys: ['?'], action: 'help', help: 'this screen, from the editor too — h/l a column, / filters it down' }, @@ -259,6 +261,19 @@ export const bindings = [ { context: 'backlinks', keys: ['G'], action: 'bottom' }, { context: 'backlinks', keys: ['Enter'], action: 'open', hint: 'open' }, + { context: 'cards', keys: ['j', 'ArrowDown'], action: 'down', caps: ['j', 'k'], hint: 'move' }, + { context: 'cards', keys: ['k', 'ArrowUp'], action: 'up' }, + { context: 'cards', keys: ['G'], action: 'bottom' }, + { context: 'cards', keys: ['Enter'], action: 'turn', hint: 'turn over' }, + { context: 'cards', keys: ['Space'], action: 'done', hint: 'done' }, + { context: 'cards', keys: ['H'], action: 'sendLeft', caps: ['H', 'L'], hint: 'send' }, + { context: 'cards', keys: ['L'], action: 'sendRight' }, + { context: 'cards', keys: ['D'], action: 'due', hint: 'due' }, + { context: 'cards', keys: ['o', 'c'], action: 'new', caps: ['o'], hint: 'new' }, + { context: 'cards', keys: ['a'], action: 'label', hint: 'label' }, + // The sheet stays up behind the line, so `:due` and `:repeat` know which card. + { context: 'cards', keys: [':'], action: 'line' }, + { context: 'history', keys: ['j', 'ArrowDown'], action: 'down', caps: ['j', 'k'], hint: 'earlier / later' }, { context: 'history', keys: ['k', 'ArrowUp'], action: 'up' }, { context: 'history', keys: ['G'], action: 'bottom' }, diff --git a/web/src/lib/store.svelte.ts b/web/src/lib/store.svelte.ts index fd2c69b..51b0e75 100644 --- a/web/src/lib/store.svelte.ts +++ b/web/src/lib/store.svelte.ts @@ -7,6 +7,7 @@ import { type AgendaCard, type Backlink, type Board, + type BoardList, type Card, type Crumb, type DiaryEntry, @@ -35,7 +36,7 @@ export type View = Exclude<TileKind, 'node'> /** What floats over the tiles; one at a time. */ export type Sheet = 'help' | 'media' | 'overview' | 'actions' | 'verify' | 'info' | 'trash' /** The info sheet's parts: what the open document holds, and what is around it. */ -export type InfoSection = 'outline' | 'history' | 'backlinks' | 'share' +export type InfoSection = 'outline' | 'history' | 'backlinks' | 'cards' | 'share' export type Flash = { text: string; kind: 'info' | 'error' } | null /** @@ -137,6 +138,8 @@ class Workspace { /** Children of the current folder, or the results of a search. */ nodes = $state<NodeSummary[]>([]) + /** The cards a search found, listed after the nodes. */ + foundCards = $state<AgendaCard[]>([]) cursor = $state(0) query = $state('') @@ -407,6 +410,8 @@ class Workspace { /** `:backlinks`: the documents that link to the open one. */ backlinks = $state<Backlink[]>([]) backlinksCursor = $state(0) + /** The info sheet's cards: where its cursor is in the open document's. */ + cardsCursor = $state(0) overview = $state<SpaceOverview[]>([]) overviewCursor = $state(0) @@ -434,6 +439,16 @@ class Workspace { return this.nodes[this.cursor] } + /** How many rows the list has to walk: the nodes, then the cards a search found. */ + get rows(): number { + return this.nodes.length + this.foundCards.length + } + + /** The card a search found under the cursor, past the nodes it found. */ + get foundCard(): AgendaCard | undefined { + return this.cursor >= this.nodes.length ? this.foundCards[this.cursor - this.nodes.length] : undefined + } + /** * What a verb acts on: the open document while the document pane has focus, * the row under the cursor while the list does. Walking the list with a @@ -460,6 +475,38 @@ class Workspace { return this.list?.cards[this.cardCursor] } + /** + * The card the card verbs act on, wherever it is looked at: picked in the + * info sheet, under the board's cursor or turned over beside it, + * highlighted in the agenda — or, reading a document, the one open card + * about it, when there is only one. + */ + get focusedCard(): Card | AgendaCard | undefined { + const about = this.open?.cards ?? [] + if (this.overlay === 'info' && this.infoSection === 'cards') return about[this.cardsCursor] + switch (this.focus) { + case 'board': + case 'card': + return this.card + case 'agenda': + return this.agendaCards[this.agendaCursor] + case 'node': { + const open = about.filter((card) => card.done_at === null) + return open.length === 1 ? open[0] : undefined + } + } + } + + /** The focused card, or `undefined` once it has been said why there is none. */ + #needCard(): Card | AgendaCard | undefined { + const card = this.focusedCard + if (card) return card + const open = this.focus === 'node' ? (this.open?.cards ?? []).filter((c) => c.done_at === null).length : 0 + if (open > 1) this.say(`${open} open cards about this — gc picks one`, 'error') + else if (this.focus === 'node' && this.open?.kind === 'document') this.say('no open card about this document — c makes one', 'error') + else this.say('no card in focus — :board', 'error') + } + /** * The agenda in the order it is drawn and walked, bucketed by urgency. The * day is the server's, in the workspace's timezone, so a browser on a trip @@ -946,11 +993,20 @@ class Workspace { /** `keep` follows a node that just arrived, instead of holding the cursor. */ async refresh(keep?: number) { const keepId = keep ?? this.selected?.id - this.nodes = this.query - ? await api.listNodes({ q: this.query, space: this.globalSearch ? undefined : (this.spaceId ?? undefined) }) - : await api.listNodes({ parent: this.parentId ?? undefined }) + if (this.query) { + const space = this.#searchSpace + ;[this.nodes, this.foundCards] = await Promise.all([api.listNodes({ q: this.query, space }), api.searchCards(this.query, space)]) + } else { + this.nodes = await api.listNodes({ parent: this.parentId ?? undefined }) + this.foundCards = [] + } const index = this.nodes.findIndex((n) => n.id === keepId) - this.cursor = index >= 0 ? index : Math.min(this.cursor, Math.max(this.nodes.length - 1, 0)) + this.cursor = index >= 0 ? index : Math.min(this.cursor, Math.max(this.rows - 1, 0)) + } + + /** The space a search keeps to; none for one across every space. */ + get #searchSpace(): number | undefined { + return this.globalSearch ? undefined : (this.spaceId ?? undefined) } async search(query: string, global = this.globalSearch) { @@ -962,25 +1018,25 @@ class Workspace { /** * `n` / `N`: the next or previous search result after the cursor, opened - * beside the results so the next `n` goes on from there. A folder is only - * highlighted: going into it would end the search. Past the last result it - * starts again at the first, as vim's `wrapscan` does. + * beside the results so the next `n` goes on from there. A folder or a card + * is only highlighted: going to it would end the search. Past the last + * result it starts again at the first, as vim's `wrapscan` does. */ async nextResult(delta: 1 | -1) { if (!this.query) return this.say('no search — / starts one', 'error') - const count = this.nodes.length + const count = this.rows if (count === 0) return this.say(`no results for "${this.query}"`, 'error') const next = this.cursor + delta if (next >= count) this.say('search hit BOTTOM, continuing at TOP') else if (next < 0) this.say('search hit TOP, continuing at BOTTOM') this.cursor = (next + count) % count - const node = this.nodes[this.cursor] - if (!isContainer(node)) await this.openNode(node.id) + const node = this.selected + if (node && !isContainer(node)) await this.openNode(node.id) } move(delta: number) { - if (this.nodes.length === 0) return - this.cursor = Math.min(Math.max(this.cursor + delta, 0), this.nodes.length - 1) + if (this.rows === 0) return + this.cursor = Math.min(Math.max(this.cursor + delta, 0), this.rows - 1) } async openNode(id: number, edit = false) { @@ -1028,6 +1084,8 @@ class Workspace { /** What Enter does: descend into a folder, open a document or a file. */ async openSelected(edit = false) { + const found = this.foundCard + if (found) return this.turnTo(found) const selected = this.selected if (!selected) return // A match from anywhere opens beside the results, as one from this space @@ -1470,44 +1528,13 @@ class Workspace { await this.showView('board') } - /** Space in the agenda: into that board's done list, from wherever it is. */ - async finishCard(card: AgendaCard) { - const done = (await api.board(card.space_id)).lists.at(-1) - if (!done) return - const moved = await api.moveCard(card.id, done.id, done.cards.length) - await this.loadAgenda() - if (this.boardSpaceId === card.space_id) await this.reloadBoard() - this.say(doneSaid(moved)) - } - - /** `:due` or `:repeat` with the agenda up: the highlighted card, on its own board. */ - async patchAgendaCard(patch: Parameters<typeof api.updateCard>[1]) { - const card = this.agendaCards[this.agendaCursor] - if (!card) return this.say('no card highlighted', 'error') - const updated = await api.updateCard(card.id, patch) - await this.loadAgenda() - // It may have changed group; the cursor goes with it. - const at = this.agendaCards.findIndex((c) => c.id === card.id) - if (at >= 0) this.agendaCursor = at - if (this.boardSpaceId === card.space_id) await this.reloadBoard() - return updated - } - /** - * `H` / `L` in the agenda: the card to the list left or right of the one it - * is in, without going to the board — `L` on the last list but one is done. + * Enter on a card a search found, or one picked in a document's info + * sheet: to its board, turned over beside it. */ - async sendAgendaCard(card: AgendaCard, delta: number) { - const lists = (await api.board(card.space_id)).lists - const from = lists.findIndex((list) => list.id === card.list_id) - const target = lists[from + delta] - if (from < 0 || !target) return this.say(`${card.title} is already in the ${delta < 0 ? 'first' : 'last'} list`, 'error') - const moved = await api.moveCard(card.id, target.id, target.cards.length) - await this.loadAgenda() - const at = this.agendaCards.findIndex((c) => c.id === card.id) - if (at >= 0) this.agendaCursor = at - if (this.boardSpaceId === card.space_id) await this.reloadBoard() - this.say(from + delta === lists.length - 1 ? doneSaid(moved) : `"${card.title}" → ${target.name}`) + async turnTo(card: AgendaCard) { + await this.goToCard(card) + await this.turnCard() } async loadOverview() { @@ -1721,25 +1748,75 @@ class Workspace { await this.reloadBoard(this.boardSpaceId, card.id) } - /** `H`/`L`: the same card, one list over — the move a board is really for. */ - async sendCard(delta: number) { - await this.sendCardTo(this.listCursor + delta) + /** + * `H`/`L`: the same card, one list over — the move a board is really for. + * From the agenda or a document too, without going to the board. + */ + async sendCard(delta: number, card = this.#needCard()) { + if (!card) return + const lists = await this.#listsOf(card) + await this.#sendTo(card, lists, lists.findIndex((list) => list.id === card.list_id) + delta) + } + + /** The digits on the board: the card under the cursor to that list. */ + async sendCardTo(index: number) { + const card = this.card + if (!card || index === this.listCursor) return + await this.#sendTo(card, this.board?.lists ?? [], index) + } + + /** The lists of a card's board: the one shown, or read for it. */ + async #listsOf(card: Card | AgendaCard): Promise<BoardList[]> { + const shown = this.board?.lists ?? [] + if (shown.some((list) => list.id === card.list_id)) return shown + return 'space_id' in card ? (await api.board(card.space_id)).lists : [] } /* * The last list is "done": the server stamps a card finished as it arrives * there and clears the stamp as it leaves, so moving is all there is to it. */ - async sendCardTo(index: number) { - const card = this.card - const lists = this.board?.lists ?? [] + async #sendTo(card: Card, lists: BoardList[], index: number) { const target = lists[index] - if (!card || !target || index === this.listCursor) return - const moved = await api.moveCard(card.id, target.id, target.cards.length) - await this.reloadBoard(this.boardSpaceId, card.id) + if (!target) return this.say(`"${card.title}" is already in the ${index < 0 ? 'first' : 'last'} list`, 'error') + const moved = await api.moveCard(card.id, target.id) + await this.#cardChanged(card) this.say(index === lists.length - 1 ? doneSaid(moved) : `"${card.title}" → ${target.name}`) } + /** + * After a card changed, wherever it was changed from: the board, the + * agenda and the open documents read it again, and the cursor that was on + * it stays on it. + */ + async #cardChanged(card: Card) { + const shown = this.rawBoard?.lists.some((list) => list.cards.some((c) => c.id === card.id)) ?? false + const here = this.focus === 'board' || this.focus === 'card' + if (shown) await this.reloadBoard(this.boardSpaceId, here ? card.id : this.card?.id) + if (!shown || this.focus === 'agenda') { + await this.loadAgenda() + // It may have changed group; the cursor goes with it. + const at = this.agendaCards.findIndex((c) => c.id === card.id) + if (at >= 0 && this.focus === 'agenda') this.agendaCursor = at + } + await this.#reloadCardsAbout() + if (this.foundCards.some((c) => c.id === card.id)) this.foundCards = await api.searchCards(this.query, this.#searchSpace) + const at = this.open?.cards.findIndex((c) => c.id === card.id) ?? -1 + if (at >= 0) this.cardsCursor = at + } + + /** The cards about each document open in a tile, read again. */ + async #reloadCardsAbout() { + await Promise.all( + this.tiles.map(async (tile) => { + const id = tile.open?.kind === 'document' ? tile.open.id : null + if (id === null) return + const cards = await api.nodeCards(id).catch(() => null) + if (cards && tile.open?.id === id) tile.open.cards = cards + }), + ) + } + /** * A card lands in the first list — the inbox — of this space's board, or of * the first space that has one, without leaving the view you are in: writing @@ -1752,6 +1829,7 @@ class Workspace { if (!list) return this.say('no list to put it on', 'error') const card = await api.createCard(list.id, { title, node_id: nodeId }) await this.reloadBoard(this.boardSpaceId, card.id) + await this.#reloadCardsAbout() return this.say(nodeId === null ? `carded "${card.title}"` : `carded "${card.title}" → #${nodeId}`) } @@ -1761,30 +1839,25 @@ class Workspace { if (!inbox) return this.say(`${space.slug} has no lists — :board, then :list <name>`, 'error') const card = await api.createCard(inbox.id, { title, node_id: nodeId }) await this.loadAgenda() + await this.#reloadCardsAbout() this.say(`carded "${card.title}" → ${space.slug}/${inbox.name}`) } - async patchCard(patch: Parameters<typeof api.updateCard>[1]) { - const card = this.card - if (!card) return this.say('no card selected', 'error') + /** A change to the focused card — or to `card`, when it is known which. */ + async patchCard(patch: Parameters<typeof api.updateCard>[1], card = this.#needCard()) { + if (!card) return const updated = await api.updateCard(card.id, patch) - await this.reloadBoard(this.boardSpaceId, card.id) + await this.#cardChanged(card) return updated } /** Space: into the done list, or back out of it to the inbox. */ - async toggleDone() { - const card = this.card - const lists = this.board?.lists ?? [] - if (!card) return this.say('no card selected', 'error') - if (lists.length < 2) return this.say('a board needs two lists for one to mean done', 'error') - const last = lists.length - 1 - if (this.listCursor === last) { - await this.sendCardTo(0) - this.say(`reopened: ${card.title}`) - } else { - await this.sendCardTo(last) - } + async toggleDone(card = this.#needCard()) { + if (!card) return + const done = card.done_at === null + const moved = await api.updateCard(card.id, { done }) + await this.#cardChanged(card) + this.say(done ? doneSaid(moved) : `reopened: ${card.title}`) } async deleteCard() { @@ -1972,7 +2045,8 @@ class Workspace { async saveDescription() { const card = this.card if (!card || !this.describing) return - await this.patchCard({ body: this.description }) + // Named, as this also runs when the focus has just left the card. + await this.patchCard({ body: this.description }, card) this.descriptionDirty = false this.say(`"${card.title}" described`) } @@ -2003,7 +2077,7 @@ class Workspace { /** The checklist written back whole, with the cursor on step `at`. */ async writeChecklist(checklist: Task[], at = this.taskCursor) { - await this.patchCard({ checklist }) + await this.patchCard({ checklist }, this.card) this.taskCursor = Math.min(Math.max(at, 0), Math.max(checklist.length - 1, 0)) } @@ -2071,11 +2145,11 @@ class Workspace { /** `:label work`: on the card, or off it when it is on already. */ toggleLabel(name: string) { return this.inTurn(async () => { - const card = this.card - if (!card) return this.say('no card selected', 'error') + const card = this.#needCard() + if (!card) return const label = name.trim().toLowerCase() const on = card.labels.includes(label) - await this.patchCard({ labels: on ? card.labels.filter((l) => l !== label) : [...card.labels, label] }) + await this.patchCard({ labels: on ? card.labels.filter((l) => l !== label) : [...card.labels, label] }, card) this.say(on ? `${label} off` : `${label} on`) }) }