cards: a document and its cards, seen from both ends

A document now lists the cards about it: the ones whose notes it is and
the ones whose description links to it. GET /nodes/{id}/cards answers
that, and the node itself carries it as `cards`. A line under the
document's meta line says what is still open and when it is first due;
it and `gc` open a new cards part of the info sheet, where j/k picks a
card, Space, H/L, D, a and `:` act on it, and Enter turns it over on its
board. `c` while reading a document makes a card about it.

Card verbs act on the card in focus wherever it is looked at: the
board's cursor, the card turned over, the agenda's highlight, the card
picked in the info sheet, or a document's only open card. patchCard,
sendCard and toggleDone take that card, so patchAgendaCard,
sendAgendaCard and finishCard are gone, and :due, :repeat, :label and
:done no longer refuse to run away from the board.

Migration 0027 adds card_links, links for a card's description: indexed
on every write, backfilled on start, followed and rewritten on a rename
or move like a document's, and claimed when a title they named appears.
It also adds cards_fts over title, description and checklist steps;
GET /cards?q= searches it, and / lists the cards found after the nodes,
Enter turning one over on its board.
This commit is contained in:
nvrl committed 2026-10-22 10:45:20 +02:00
1 parent 5684614d0a
commit b8b3641e6e
21 files changed
+896 -274

No files matched your search

+5 -2
View File
@@ -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 |
+2
View File
@@ -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 |
+29 -2
View File
@@ -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 <when>`) takes a date the way it is said:
+7 -5
View File
@@ -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! <name>`, `: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 <title>` | 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 |
+4
View File
@@ -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
+49
View File
@@ -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;
+3 -3
View File
@@ -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.
+150 -48
View File
@@ -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(&params.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?
+296 -73
View File
@@ -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());
}
}
+2
View File
@@ -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))
+8 -3
View File
@@ -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,
})
}
+1 -1
View File
@@ -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}
+1 -1
View File
@@ -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
+18
View File
@@ -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 {
+53 -5
View File
@@ -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; }
+24 -3
View File
@@ -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;
+35 -14
View File
@@ -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))
}
+13 -2
View File
@@ -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)}`),
+26 -31
View File
@@ -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)
},
},
+17 -2
View File
@@ -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' },
+153 -79
View File
@@ -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`)
})
}