mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
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:
1 parent
5684614d0a
commit
b8b3641e6e
21 files changed
+896
-274
No files matched your search
@@ -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 |
|
| `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 |
|
| `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 |
|
| `]]` / `[[` | 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`) |
|
| `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 |
|
| `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`) |
|
| `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`) |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `T` | today's diary entry, started if there is none |
|
||||||
| `C` | the diary by month (`:cal`) — see [The diary](#the-diary) |
|
| `C` | the diary by month (`:cal`) — see [The diary](#the-diary) |
|
||||||
| `[d` / `]d` | the diary entry before / after the one open, by date |
|
| `[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) |
|
| `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 |
|
| `Esc`, `q` | close the document, and its tile when there are several |
|
||||||
| `h` | up one level in the tree |
|
| `h` | up one level in the tree |
|
||||||
@@ -882,6 +883,7 @@ sealed or not.
|
|||||||
| `POST` | `/api/lists/{id}/cards` | add a card |
|
| `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` |
|
| `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 |
|
| `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 }` |
|
| `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 |
|
| `PUT`/`DELETE` | `/api/scratch/{id}` | rewrite a note `{ body }` / drop it |
|
||||||
| `POST` | `/api/nodes/{id}/move` | reparent, and place among the new siblings |
|
| `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` | a document's earlier versions, the newest first |
|
||||||
| `GET` | `/api/nodes/{id}/revisions/{rev}` | one earlier version, with its text |
|
| `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}/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 |
|
| `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` | `/api/export` | the whole workspace, boards and dates included, as a zip |
|
||||||
| `GET`/`POST` | `/api/backup` | Proton Drive mirror: status / run now |
|
| `GET`/`POST` | `/api/backup` | Proton Drive mirror: status / run now |
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ sealed or not.
|
|||||||
| `POST` | `/api/lists/{id}/cards` | add a card |
|
| `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` |
|
| `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 |
|
| `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 }` |
|
| `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 |
|
| `PUT`/`DELETE` | `/api/scratch/{id}` | rewrite a note `{ body }` / drop it |
|
||||||
| `POST` | `/api/nodes/{id}/move` | reparent, and place among the new siblings |
|
| `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` | a document's earlier versions, the newest first |
|
||||||
| `GET` | `/api/nodes/{id}/revisions/{rev}` | one earlier version, with its text |
|
| `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}/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 |
|
| `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` | `/api/export` | the whole workspace, boards and dates included, as a zip |
|
||||||
| `GET`/`POST` | `/api/backup` | Proton Drive mirror: status / run now |
|
| `GET`/`POST` | `/api/backup` | Proton Drive mirror: status / run now |
|
||||||
|
|||||||
@@ -111,17 +111,44 @@ step open again.
|
|||||||
|
|
||||||
`c` in the tree opens `:card ` on the command line. The card goes
|
`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
|
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
|
### Notes
|
||||||
|
|
||||||
`gf` on a card, or on its back, opens its notes: a document named after the card, made in the
|
`gf` on a card, or on its back, opens its notes: a document named after the card, made in the
|
||||||
root of the space the first time, in a tile beside the board. Closing it goes
|
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
|
The link is loose on purpose: deleting the document leaves the card, because the
|
||||||
work is still open when its notes are gone.
|
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
|
### Due dates
|
||||||
|
|
||||||
`D` on a card (or `:due <when>`) takes a date the way it is said:
|
`D` on a card (or `:due <when>`) takes a date the way it is said:
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ arguments and `↑` / `↓` recall earlier lines.
|
|||||||
| `Ctrl-D / Ctrl-U` | half a page down / up the open document |
|
| `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 |
|
| `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 |
|
| `]] / \[\[` | 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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `[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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `/` | 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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `:` | command line — Tab / Shift-Tab cycle the matches, then the argument; ↑ ↓ recall what ran before |
|
||||||
| `Ctrl-S` | save, from anywhere |
|
| `Ctrl-S` | save, from anywhere |
|
||||||
| `?` | this screen, from the editor too — h/l a column, / filters it down |
|
| `?` | 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 |
|
| `: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, :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 |
|
| `: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 |
|
| `: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, :step <text>` | add a step to the open card's checklist, below the cursor |
|
||||||
| `:task!` | reword the step under 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 |
|
| `: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 |
|
| `: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) |
|
| `: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) |
|
| `: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 |
|
| `: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 |
|
| `: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, :se [text]` | full-text search in this space (empty clears) |
|
||||||
| `:search!` | full-text search across every space — Ctrl-P |
|
| `:search!` | full-text search across every space — Ctrl-P |
|
||||||
| `:clear, :noh` | clear the search filter |
|
| `:clear, :noh` | clear the search filter |
|
||||||
|
|||||||
@@ -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
|
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.
|
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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
Each word you type matches the start of a word, so `inge` finds "ingest". All
|
Each word you type matches the start of a word, so `inge` finds "ingest". All
|
||||||
|
|||||||
@@ -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
@@ -148,9 +148,9 @@ async fn main() -> Result<()> {
|
|||||||
// Before the first request, so no document reads as linked from nowhere.
|
// Before the first request, so no document reads as linked from nowhere.
|
||||||
match routes::links::backfill(&state.db).await {
|
match routes::links::backfill(&state.db).await {
|
||||||
Ok(0) => {}
|
Ok(0) => {}
|
||||||
Ok(1) => tracing::info!("indexed the links of 1 document"),
|
Ok(1) => tracing::info!("indexed the links of 1 document or card"),
|
||||||
Ok(linking) => tracing::info!("indexed the links of {linking} documents"),
|
Ok(linking) => tracing::info!("indexed the links of {linking} documents and cards"),
|
||||||
Err(e) => tracing::warn!("could not index the links between documents: {e:?}"),
|
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
|
// 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.
|
// first start, so the server does not wait for it.
|
||||||
|
|||||||
+150
-48
@@ -17,7 +17,7 @@
|
|||||||
//! work is still open once its notes are gone.
|
//! work is still open once its notes are gone.
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State},
|
extract::{Path, Query, State},
|
||||||
Json,
|
Json,
|
||||||
};
|
};
|
||||||
use std::collections::{BTreeMap, HashMap};
|
use std::collections::{BTreeMap, HashMap};
|
||||||
@@ -32,8 +32,9 @@ use crate::{
|
|||||||
routes::{
|
routes::{
|
||||||
links,
|
links,
|
||||||
manifest::ListMeta,
|
manifest::ListMeta,
|
||||||
nodes::{DOCUMENT, SPACE},
|
nodes::{self, DOCUMENT, SPACE},
|
||||||
repeat::Repeat,
|
repeat::Repeat,
|
||||||
|
trash,
|
||||||
},
|
},
|
||||||
state::AppState,
|
state::AppState,
|
||||||
};
|
};
|
||||||
@@ -261,10 +262,54 @@ pub struct AgendaCard {
|
|||||||
pub space_id: i64,
|
pub space_id: i64,
|
||||||
pub space_slug: String,
|
pub space_slug: String,
|
||||||
pub list_name: 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,
|
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
|
/// 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
|
/// 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.
|
/// 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
|
/// The agenda's cards, due before `horizon` or in hand. Apart from the handler
|
||||||
/// so the selection can be tested against a real schema.
|
/// so the selection can be tested against a real schema.
|
||||||
async fn agenda_cards(db: &SqlitePool, horizon: i64) -> AppResult<Vec<AgendaCard>> {
|
async fn agenda_cards(db: &SqlitePool, horizon: i64) -> AppResult<Vec<AgendaCard>> {
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(&format!(
|
||||||
"WITH bounds AS (
|
"{PLACED}
|
||||||
SELECT space_id, MIN(position) AS first, MAX(position) AS last
|
WHERE c.done_at IS NULL
|
||||||
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
|
|
||||||
AND (l.position < b.last OR b.first = b.last)
|
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))
|
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)
|
.bind(horizon)
|
||||||
.fetch_all(db)
|
.fetch_all(db)
|
||||||
.await?;
|
.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()
|
.iter()
|
||||||
.map(|row| AgendaCard {
|
.map(row_to_placed)
|
||||||
card: row_to_card(row),
|
.partition(|card| card.card.done_at.is_none());
|
||||||
space_id: row.get("space_id"),
|
Ok(open
|
||||||
space_slug: row.get("space_slug"),
|
.into_iter()
|
||||||
list_name: row.get("list_name"),
|
.chain(done.into_iter().take(FINISHED_SHOWN))
|
||||||
role: if row.get::<bool, _>("is_first") {
|
|
||||||
"inbox"
|
|
||||||
} else {
|
|
||||||
"doing"
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.collect())
|
.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
|
/// `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
|
/// last list is `last`. Shuffling inside one list changes nothing; arriving in
|
||||||
/// the last list finishes the card (keeping an earlier stamp), leaving it
|
/// 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)
|
.fetch_one(&mut *db)
|
||||||
.await?;
|
.await?;
|
||||||
for card in &list.cards {
|
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,
|
"INSERT INTO cards (list_id, node_id, title, body, due_at, done_at, repeat,
|
||||||
checklist, labels, position, created_at, updated_at)
|
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(id)
|
||||||
.bind(card.document.as_ref().and_then(|path| documents.get(path)))
|
.bind(card.document.as_ref().and_then(|path| documents.get(path)))
|
||||||
@@ -575,8 +671,9 @@ pub(crate) async fn restore(
|
|||||||
.bind(position)
|
.bind(position)
|
||||||
.bind(card.created_at)
|
.bind(card.created_at)
|
||||||
.bind(card.updated_at)
|
.bind(card.updated_at)
|
||||||
.execute(&mut *db)
|
.fetch_one(&mut *db)
|
||||||
.await?;
|
.await?;
|
||||||
|
links::index_card(db, card_id, &card.body).await?;
|
||||||
position += 1;
|
position += 1;
|
||||||
count += 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
|
// 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.
|
// is small, and the round trips are what would be felt.
|
||||||
let card_rows = sqlx::query(
|
let card_rows = sqlx::query(concat!(
|
||||||
"SELECT c.id, c.list_id, c.title, c.body, c.node_id, c.due_at, c.done_at,
|
"SELECT ",
|
||||||
c.repeat, c.checklist, c.labels, c.position, c.created_at, c.updated_at,
|
card_columns!(),
|
||||||
(SELECT n.name FROM nodes n WHERE n.id = c.node_id) AS node_name
|
"
|
||||||
FROM cards c
|
FROM cards c
|
||||||
JOIN board_lists l ON l.id = c.list_id
|
JOIN board_lists l ON l.id = c.list_id
|
||||||
WHERE l.space_id = ?1
|
WHERE l.space_id = ?1
|
||||||
ORDER BY c.position, c.id",
|
ORDER BY c.position, c.id"
|
||||||
)
|
))
|
||||||
.bind(space_id)
|
.bind(space_id)
|
||||||
.fetch_all(&state.db)
|
.fetch_all(&state.db)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -818,6 +915,7 @@ pub(crate) async fn insert_card(
|
|||||||
.fetch_one(&mut *tx)
|
.fetch_one(&mut *tx)
|
||||||
.await?
|
.await?
|
||||||
.get("id");
|
.get("id");
|
||||||
|
links::index_card(&mut tx, id, &input.body).await?;
|
||||||
// A card made straight into the done list is finished as it lands.
|
// A card made straight into the done list is finished as it lands.
|
||||||
settle(&mut tx, space_id).await?;
|
settle(&mut tx, space_id).await?;
|
||||||
tx.commit().await?;
|
tx.commit().await?;
|
||||||
@@ -926,6 +1024,9 @@ pub async fn update_card(
|
|||||||
.bind(card_id)
|
.bind(card_id)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.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
|
// 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.
|
// and finished in one request comes back by that rule.
|
||||||
let current = Card {
|
let current = Card {
|
||||||
@@ -1155,6 +1256,7 @@ async fn come_back(
|
|||||||
.bind(now)
|
.bind(now)
|
||||||
.fetch_one(&mut *db)
|
.fetch_one(&mut *db)
|
||||||
.await?;
|
.await?;
|
||||||
|
links::index_card(db, id, &card.body).await?;
|
||||||
|
|
||||||
sqlx::query("UPDATE cards SET repeat = NULL WHERE id = ?1")
|
sqlx::query("UPDATE cards SET repeat = NULL WHERE id = ?1")
|
||||||
.bind(card.id)
|
.bind(card.id)
|
||||||
@@ -1175,12 +1277,12 @@ fn reopened(tasks: &[Task]) -> Vec<Task> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn card(db: &SqlitePool, id: i64) -> AppResult<Card> {
|
async fn card(db: &SqlitePool, id: i64) -> AppResult<Card> {
|
||||||
let row = sqlx::query(
|
let row = sqlx::query(concat!(
|
||||||
"SELECT c.id, c.list_id, c.title, c.body, c.node_id, c.due_at, c.done_at,
|
"SELECT ",
|
||||||
c.repeat, c.checklist, c.labels, c.position, c.created_at, c.updated_at,
|
card_columns!(),
|
||||||
(SELECT n.name FROM nodes n WHERE n.id = c.node_id) AS node_name
|
"
|
||||||
FROM cards c WHERE c.id = ?1",
|
FROM cards c WHERE c.id = ?1"
|
||||||
)
|
))
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.fetch_optional(db)
|
.fetch_optional(db)
|
||||||
.await?
|
.await?
|
||||||
|
|||||||
+296
-73
@@ -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,
|
/// Where a link is written: a document's text, or a card's summary. Both are
|
||||||
/// after it, in the same transaction.
|
/// indexed, followed and rewritten alike; they are kept in tables of their own
|
||||||
pub async fn index(db: &mut SqliteConnection, from: i64, body: &str) -> AppResult<()> {
|
/// only because a card is not a node.
|
||||||
let space = space_of(db, from).await?;
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
let before: HashMap<(Kind, String), i64> =
|
enum Source {
|
||||||
sqlx::query("SELECT kind, target, to_id FROM links WHERE from_id = ?1")
|
Node(i64),
|
||||||
.bind(from)
|
Card(i64),
|
||||||
.fetch_all(&mut *db)
|
}
|
||||||
.await?
|
|
||||||
.iter()
|
impl Source {
|
||||||
.map(|row| {
|
/// The table its links are kept in, and the column that names it there.
|
||||||
(
|
fn table(self) -> (&'static str, &'static str, i64) {
|
||||||
(Kind::parse(row.get("kind")), row.get("target")),
|
match self {
|
||||||
row.get("to_id"),
|
Source::Node(id) => ("links", "from_id", id),
|
||||||
)
|
Source::Card(id) => ("card_links", "card_id", id),
|
||||||
})
|
}
|
||||||
.collect();
|
}
|
||||||
|
|
||||||
|
/// 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();
|
let mut rows = Vec::new();
|
||||||
for (kind, target) in written(body) {
|
for (kind, target) in written(body) {
|
||||||
@@ -274,31 +320,47 @@ pub async fn index(db: &mut SqliteConnection, from: i64, body: &str) -> AppResul
|
|||||||
_ => None,
|
_ => 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));
|
rows.push((kind, target, to));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sqlx::query("DELETE FROM links WHERE from_id = ?1")
|
sqlx::query(&format!("DELETE FROM {table} WHERE {column} = ?1"))
|
||||||
.bind(from)
|
.bind(id)
|
||||||
.execute(&mut *db)
|
.execute(&mut *db)
|
||||||
.await?;
|
.await?;
|
||||||
for (kind, target, to) in rows {
|
for (kind, target, to) in rows {
|
||||||
sqlx::query("INSERT INTO links (from_id, kind, target, to_id) VALUES (?1, ?2, ?3, ?4)")
|
sqlx::query(&format!(
|
||||||
.bind(from)
|
"INSERT INTO {table} ({column}, kind, target, to_id) VALUES (?1, ?2, ?3, ?4)"
|
||||||
.bind(kind.as_str())
|
))
|
||||||
.bind(&target)
|
.bind(id)
|
||||||
.bind(to)
|
.bind(kind.as_str())
|
||||||
.execute(&mut *db)
|
.bind(&target)
|
||||||
.await?;
|
.bind(to)
|
||||||
|
.execute(&mut *db)
|
||||||
|
.await?;
|
||||||
}
|
}
|
||||||
Ok(())
|
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
|
/// 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
|
/// 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
|
/// to is rewritten to find it again, in a document or on a card; a link that
|
||||||
/// names it now is recorded. Returns how many documents were rewritten.
|
/// 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> {
|
pub async fn follow(db: &mut SqliteConnection, id: i64) -> AppResult<usize> {
|
||||||
// Nothing in the trash is followed there: its links stay as they were
|
// Nothing in the trash is followed there: its links stay as they were
|
||||||
// written, for whatever the restore brings it back as.
|
// 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
|
UNION ALL
|
||||||
SELECT n.id FROM nodes n JOIN sub ON n.parent_id = sub.id
|
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)",
|
WHERE to_id IN (SELECT id FROM sub)",
|
||||||
)
|
)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.fetch_all(&mut *db)
|
.fetch_all(&mut *db)
|
||||||
.await?;
|
.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 {
|
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")),
|
Kind::parse(row.get("kind")),
|
||||||
row.get("target"),
|
row.get("target"),
|
||||||
row.get("to_id"),
|
row.get("to_id"),
|
||||||
@@ -328,8 +397,8 @@ pub async fn follow(db: &mut SqliteConnection, id: i64) -> AppResult<usize> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut rewritten = 0;
|
let mut rewritten = 0;
|
||||||
for (from, links) in linking {
|
for (source, links) in linking {
|
||||||
let space = space_of(db, from).await?;
|
let space = source.space(db).await?;
|
||||||
let mut to = HashMap::new();
|
let mut to = HashMap::new();
|
||||||
for (kind, target, node) in links {
|
for (kind, target, node) in links {
|
||||||
if resolve(db, kind, &target, space).await? == Some(node) {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let body: String = sqlx::query_scalar("SELECT body FROM nodes WHERE id = ?1")
|
let body = source.body(db).await?;
|
||||||
.bind(from)
|
|
||||||
.fetch_one(&mut *db)
|
|
||||||
.await?;
|
|
||||||
let body_now = rewrite(&body, &to);
|
let body_now = rewrite(&body, &to);
|
||||||
if body_now == body {
|
if body_now == body {
|
||||||
continue;
|
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
|
// `updated_at` moves, so an editor open on the old text is told it
|
||||||
// changed rather than saving the broken links back over it.
|
// changed rather than saving the broken links back over it.
|
||||||
sqlx::query("UPDATE nodes SET body = ?1, updated_at = ?2 WHERE id = ?3")
|
match source {
|
||||||
.bind(&body_now)
|
Source::Node(from) => {
|
||||||
.bind(crate::now())
|
revisions::keep(db, from, &body_now, revisions::Reason::Edit).await?;
|
||||||
.bind(from)
|
sqlx::query("UPDATE nodes SET body = ?1, updated_at = ?2 WHERE id = ?3")
|
||||||
.execute(&mut *db)
|
.bind(&body_now)
|
||||||
.await?;
|
.bind(crate::now())
|
||||||
index(db, from, &body_now).await?;
|
.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;
|
rewritten += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,9 +448,9 @@ pub async fn follow(db: &mut SqliteConnection, id: i64) -> AppResult<usize> {
|
|||||||
Ok(rewritten)
|
Ok(rewritten)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Index again the documents that may have been waiting for `id`: whose
|
/// Index again the documents and cards that may have been waiting for `id`:
|
||||||
/// `[[title]]` or path named nothing until it was called that, or put there.
|
/// whose `[[title]]` or path named nothing until it was called that, or put
|
||||||
/// A rough cut by text, as the index then decides what each link finds.
|
/// there. A rough cut by text, as the index then decides what each link finds.
|
||||||
async fn claim(db: &mut SqliteConnection, id: i64) -> AppResult<()> {
|
async fn claim(db: &mut SqliteConnection, id: i64) -> AppResult<()> {
|
||||||
let row = sqlx::query("SELECT name, slug FROM nodes WHERE id = ?1")
|
let row = sqlx::query("SELECT name, slug FROM nodes WHERE id = ?1")
|
||||||
.bind(id)
|
.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 name = row.get::<String, _>("name").trim().to_ascii_lowercase();
|
||||||
let slug: String = row.get("slug");
|
let slug: String = row.get("slug");
|
||||||
let path = path_text(db, id).await?;
|
let path = path_text(db, id).await?;
|
||||||
let waiting: Vec<(i64, String)> = sqlx::query_as(
|
let waiting: Vec<(bool, i64, String)> = sqlx::query_as(
|
||||||
"SELECT id, body FROM nodes
|
"SELECT 0, id, body FROM nodes
|
||||||
WHERE kind = 'document' AND id <> ?1
|
WHERE kind = 'document' AND id <> ?1
|
||||||
AND (instr(body, '/n/' || ?4) > 0
|
AND (instr(body, '/n/' || ?4) > 0
|
||||||
OR (instr(body, '[[') > 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(id)
|
||||||
.bind(&name)
|
.bind(&name)
|
||||||
@@ -395,8 +478,13 @@ async fn claim(db: &mut SqliteConnection, id: i64) -> AppResult<()> {
|
|||||||
.bind(&path)
|
.bind(&path)
|
||||||
.fetch_all(&mut *db)
|
.fetch_all(&mut *db)
|
||||||
.await?;
|
.await?;
|
||||||
for (from, body) in waiting {
|
for (card, from, body) in waiting {
|
||||||
index(db, from, &body).await?;
|
let source = if card {
|
||||||
|
Source::Card(from)
|
||||||
|
} else {
|
||||||
|
Source::Node(from)
|
||||||
|
};
|
||||||
|
record(db, source, &body).await?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -527,31 +615,52 @@ pub async fn backlinks(
|
|||||||
Ok(Json(found))
|
Ok(Json(found))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Index every document, when there is no link recorded at all: the ones
|
/// Index every document, when there is no link recorded at all, and every
|
||||||
/// written before links were kept. Returns how many documents link anywhere.
|
/// 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> {
|
pub async fn backfill(db: &SqlitePool) -> AppResult<usize> {
|
||||||
let any: bool = sqlx::query_scalar("SELECT EXISTS (SELECT 1 FROM links)")
|
let (documents, cards): (bool, bool) = sqlx::query_as(
|
||||||
.fetch_one(db)
|
"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?;
|
.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);
|
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?;
|
let mut tx = crate::db::begin_write(db).await?;
|
||||||
for row in &documents {
|
for (source, body) in &written {
|
||||||
index(&mut tx, row.get("id"), row.get("body")).await?;
|
record(&mut tx, *source, body).await?;
|
||||||
}
|
}
|
||||||
let linking: i64 = sqlx::query_scalar("SELECT COUNT(DISTINCT from_id) FROM links")
|
let linking: i64 = sqlx::query_scalar(
|
||||||
.fetch_one(&mut *tx)
|
"SELECT (SELECT COUNT(DISTINCT from_id) FROM links)
|
||||||
.await?;
|
+ (SELECT COUNT(DISTINCT card_id) FROM card_links)",
|
||||||
|
)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await?;
|
||||||
tx.commit().await?;
|
tx.commit().await?;
|
||||||
Ok(linking as usize)
|
Ok(linking as usize)
|
||||||
}
|
}
|
||||||
@@ -563,7 +672,10 @@ mod tests {
|
|||||||
use axum::extract::{Path, State};
|
use axum::extract::{Path, State};
|
||||||
|
|
||||||
use super::{rewrite, written, Kind};
|
use super::{rewrite, written, Kind};
|
||||||
use crate::{routes::nodes, state::AppState};
|
use crate::{
|
||||||
|
routes::{boards, nodes},
|
||||||
|
state::AppState,
|
||||||
|
};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn what_counts_as_a_link() {
|
fn what_counts_as_a_link() {
|
||||||
@@ -804,4 +916,115 @@ mod tests {
|
|||||||
assert_eq!(targets(&state, linker).await[0].2, plan);
|
assert_eq!(targets(&state, linker).await[0].2, plan);
|
||||||
assert_eq!(super::backfill(&state.db).await.unwrap(), 0);
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -81,6 +81,7 @@ pub fn api_router(max_upload: usize) -> Router<AppState> {
|
|||||||
"/cards/{id}",
|
"/cards/{id}",
|
||||||
put(boards::update_card).delete(boards::remove_card),
|
put(boards::update_card).delete(boards::remove_card),
|
||||||
)
|
)
|
||||||
|
.route("/cards", get(boards::search))
|
||||||
.route("/cards/{id}/move", post(boards::move_card))
|
.route("/cards/{id}/move", post(boards::move_card))
|
||||||
.route("/nodes", get(nodes::list).post(nodes::create))
|
.route("/nodes", get(nodes::list).post(nodes::create))
|
||||||
.route("/resolve", get(nodes::resolve))
|
.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}", delete(nodes::remove))
|
||||||
.route("/nodes/{id}/revisions", get(revisions::list))
|
.route("/nodes/{id}/revisions", get(revisions::list))
|
||||||
.route("/nodes/{id}/backlinks", get(links::backlinks))
|
.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}/templates", get(diary::templates))
|
||||||
.route("/nodes/{id}/revisions/{rev}", get(revisions::get))
|
.route("/nodes/{id}/revisions/{rev}", get(revisions::get))
|
||||||
.route("/nodes/move", post(nodes::move_many))
|
.route("/nodes/move", post(nodes::move_many))
|
||||||
|
|||||||
+8
-3
@@ -10,7 +10,7 @@ use sqlx::{Row, SqliteConnection, SqlitePool};
|
|||||||
use crate::{
|
use crate::{
|
||||||
auth::Session,
|
auth::Session,
|
||||||
error::{AppError, AppResult},
|
error::{AppError, AppResult},
|
||||||
routes::{diary, links, media, revisions, trash},
|
routes::{boards, diary, links, media, revisions, trash},
|
||||||
state::AppState,
|
state::AppState,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -112,6 +112,9 @@ pub struct Node {
|
|||||||
pub path: Vec<Crumb>,
|
pub path: Vec<Crumb>,
|
||||||
/// How many documents link here.
|
/// How many documents link here.
|
||||||
pub backlinks: i64,
|
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
|
/// Where each `[[title]]` in the body leads, as an /n/... path. A title
|
||||||
/// that leads nowhere is not in it.
|
/// that leads nowhere is not in it.
|
||||||
pub wikilinks: BTreeMap<String, String>,
|
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
|
/// Turn free text into an FTS5 prefix query, discarding anything that could be
|
||||||
/// read as FTS syntax.
|
/// 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
|
let terms: Vec<String> = raw
|
||||||
.split_whitespace()
|
.split_whitespace()
|
||||||
.map(|term| term.replace(|c: char| !c.is_alphanumeric(), " "))
|
.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 body: String = row.get("body");
|
||||||
let summary = row_to_summary(&row, &body.chars().take(HEAD_CHARS).collect::<String>());
|
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?;
|
let mut conn = state.db.acquire().await?;
|
||||||
(
|
(
|
||||||
links::linked_from(&mut conn, id).await?,
|
links::linked_from(&mut conn, id).await?,
|
||||||
links::wikilinks(&mut conn, id, &body).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,
|
file: summary.file,
|
||||||
path: path_of(&state.db, id).await?,
|
path: path_of(&state.db, id).await?,
|
||||||
backlinks,
|
backlinks,
|
||||||
|
cards,
|
||||||
wikilinks,
|
wikilinks,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,7 +77,7 @@
|
|||||||
</button>
|
</button>
|
||||||
{#if isTouchDevice}
|
{#if isTouchDevice}
|
||||||
<!-- Space, for a thumb. -->
|
<!-- 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} />
|
<Icon name="check" size={18} />
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -129,7 +129,7 @@
|
|||||||
const query = line.slice(1)
|
const query = line.slice(1)
|
||||||
await workspace.guard(async () => {
|
await workspace.guard(async () => {
|
||||||
if (query && workspace.query !== query) await workspace.search(query, global)
|
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()
|
hooks.focusList()
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import { workspace, type Tile } from './store.svelte'
|
import { workspace, type Tile } from './store.svelte'
|
||||||
import Icon from './Icon.svelte'
|
import Icon from './Icon.svelte'
|
||||||
import {
|
import {
|
||||||
|
formatDay,
|
||||||
formatStamp,
|
formatStamp,
|
||||||
formatWeekday,
|
formatWeekday,
|
||||||
headingsIn,
|
headingsIn,
|
||||||
@@ -26,6 +27,9 @@
|
|||||||
const words = $derived(
|
const words = $derived(
|
||||||
(tile.editing ? tile.draft.body : doc.body).split(/\s+/).filter(Boolean).length,
|
(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 reader = $state<HTMLElement>()
|
||||||
let article = $state<HTMLElement>()
|
let article = $state<HTMLElement>()
|
||||||
let title = $state<HTMLInputElement>()
|
let title = $state<HTMLInputElement>()
|
||||||
@@ -162,6 +166,18 @@
|
|||||||
<span class="field public"><span class="ring"></span>public</span>
|
<span class="field public"><span class="ring"></span>public</span>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</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>
|
</header>
|
||||||
|
|
||||||
{#if tile.editing}
|
{#if tile.editing}
|
||||||
@@ -277,6 +293,8 @@
|
|||||||
.meta:hover .caption, .meta:focus-visible .caption { color: var(--light); }
|
.meta:hover .caption, .meta:focus-visible .caption { color: var(--light); }
|
||||||
.caption { margin-right: 8px; color: var(--muted); letter-spacing: 0.18em; text-transform: uppercase; }
|
.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); }
|
.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; }
|
.ring { width: 7px; height: 7px; flex: none; border: 1.5px solid var(--light); border-radius: 999px; }
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
|
|||||||
+53
-5
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts" module>
|
<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 { workspace, type InfoSection } from './store.svelte'
|
||||||
import { formatStamp } from './util'
|
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
|
* 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
|
* 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,
|
* 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. */
|
/** Tab and Shift-Tab: the next part along, round the ends. */
|
||||||
export function turnInfo(by: number) {
|
export function turnInfo(by: number) {
|
||||||
@@ -44,6 +44,12 @@
|
|||||||
return workspace.guard(() => workspace.openPath(link.path))
|
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.
|
// Sharing is a key away; taking a link back from its readers asks first.
|
||||||
export async function toggleSharing(node: NodeSummary | null | undefined) {
|
export async function toggleSharing(node: NodeSummary | null | undefined) {
|
||||||
if (!node) return workspace.say('nothing selected', 'error')
|
if (!node) return workspace.say('nothing selected', 'error')
|
||||||
@@ -66,14 +72,28 @@
|
|||||||
restore: 'replaced by a restore',
|
restore: 'replaced by a restore',
|
||||||
}
|
}
|
||||||
const reason = (summary: RevisionSummary) => REASONS[summary.reason] ?? summary.reason
|
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>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Sheet from './Sheet.svelte'
|
import Sheet from './Sheet.svelte'
|
||||||
import { diffLines, folded } from './diff'
|
import { diffLines, folded } from './diff'
|
||||||
import { relative } from './util'
|
import { formatDay, relative } from './util'
|
||||||
|
|
||||||
const doc = $derived(workspace.open)
|
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)
|
const section = $derived(workspace.infoSection)
|
||||||
|
|
||||||
// The shallowest level in the document is the left edge, so a text that
|
// The shallowest level in the document is the left edge, so a text that
|
||||||
@@ -95,11 +115,18 @@
|
|||||||
['outline', `outline ${workspace.outline.length}`],
|
['outline', `outline ${workspace.outline.length}`],
|
||||||
['history', `history ${workspace.history.length}`],
|
['history', `history ${workspace.history.length}`],
|
||||||
['backlinks', `linked from ${workspace.backlinks.length}`],
|
['backlinks', `linked from ${workspace.backlinks.length}`],
|
||||||
|
['cards', `cards ${doc?.cards.filter((card) => card.done_at === null).length ?? 0}`],
|
||||||
['share', doc?.shared ? 'shared' : 'private'],
|
['share', doc?.shared ? 'shared' : 'private'],
|
||||||
])
|
])
|
||||||
|
|
||||||
const cursor = $derived(
|
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>
|
</script>
|
||||||
|
|
||||||
@@ -186,6 +213,25 @@
|
|||||||
<li class="muted">nothing links here yet — a link is [text](/n/…) or [[title]].</li>
|
<li class="muted">nothing links here yet — a link is [text](/n/…) or [[title]].</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</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}
|
{:else}
|
||||||
<div class="share">
|
<div class="share">
|
||||||
{#if sharePath}
|
{#if sharePath}
|
||||||
@@ -236,6 +282,8 @@
|
|||||||
.row.added { background: color-mix(in oklab, var(--light) 14%, transparent); }
|
.row.added { background: color-mix(in oklab, var(--light) 14%, transparent); }
|
||||||
.row.fold { color: var(--muted); }
|
.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; }
|
.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; }
|
.share { display: flex; flex-direction: column; gap: 12px; font-size: 12px; }
|
||||||
|
|||||||
@@ -77,7 +77,10 @@
|
|||||||
<div class="head label rule">
|
<div class="head label rule">
|
||||||
<span class="marker"></span>
|
<span class="marker"></span>
|
||||||
<span class="name">{workspace.space?.slug ?? 'diary'}</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>
|
</div>
|
||||||
|
|
||||||
{#if workspace.query}
|
{#if workspace.query}
|
||||||
@@ -144,7 +147,22 @@
|
|||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</li>
|
</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">
|
<li class="empty muted">
|
||||||
{#if workspace.query}
|
{#if workspace.query}
|
||||||
no matches
|
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
|
nothing here yet — <span class="kbd">o</span> writes, <span class="kbd">⇧ O</span> makes a folder, files dropped here land here
|
||||||
{/if}
|
{/if}
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/if}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -246,6 +264,9 @@
|
|||||||
}
|
}
|
||||||
.row.open .dot { background: var(--light); }
|
.row.open .dot { background: var(--light); }
|
||||||
.row.open.active .dot { background: var(--ink); }
|
.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 {
|
.title {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|||||||
+35
-14
@@ -34,7 +34,7 @@ const spaceIndex = (key: string) => {
|
|||||||
const reading = () => !!workspace.open && !workspace.editing && workspace.pane === 'document'
|
const reading = () => !!workspace.open && !workspace.editing && workspace.pane === 'document'
|
||||||
|
|
||||||
/** A cursor down a sheet's list, kept on it. */
|
/** 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))
|
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. */
|
/** Its history: j/k through the versions, Enter brings the one shown back. */
|
||||||
history: {
|
history: {
|
||||||
down: () => moveHistory(1),
|
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)),
|
down: () => void (reading() ? hooks.scrollReader(1) : workspace.move(1)),
|
||||||
up: () => 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)),
|
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) => {
|
open: (key) => {
|
||||||
const enter = key === 'Enter' || key === 'Shift-Enter'
|
const enter = key === 'Enter' || key === 'Shift-Enter'
|
||||||
if (enter && reading() && hooks.fileAction()) return
|
if (enter && reading() && hooks.fileAction()) return
|
||||||
@@ -341,6 +368,7 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
|
|||||||
outline: () => void runCommand('outline'),
|
outline: () => void runCommand('outline'),
|
||||||
history: () => void runCommand('history'),
|
history: () => void runCommand('history'),
|
||||||
backlinks: () => void runCommand('backlinks'),
|
backlinks: () => void runCommand('backlinks'),
|
||||||
|
cards: () => void runCommand('cards'),
|
||||||
inTab: () => openInTab(),
|
inTab: () => openInTab(),
|
||||||
// `f` already fills the screen, so the hints vimium puts on `f` live on
|
// `f` already fills the screen, so the hints vimium puts on `f` live on
|
||||||
// its other key.
|
// its other key.
|
||||||
@@ -357,7 +385,8 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
|
|||||||
drop: () => guard(() => workspace.drop()),
|
drop: () => guard(() => workspace.drop()),
|
||||||
new: () => void runCommand('new'),
|
new: () => void runCommand('new'),
|
||||||
// Capture: a thought goes on this space's inbox without leaving here.
|
// 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
|
// The name has to come from somewhere, and the command line is where
|
||||||
// names are typed — this only saves typing `:mkdir `.
|
// names are typed — this only saves typing `:mkdir `.
|
||||||
mkdir: () => line(':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.
|
// Beside the agenda, so `q` on it comes back here.
|
||||||
else if (past) guard(() => workspace.openPath(past.path))
|
else if (past) guard(() => workspace.openPath(past.path))
|
||||||
},
|
},
|
||||||
finish: () => {
|
finish: () => guard(() => workspace.toggleDone()),
|
||||||
const card = workspace.agendaCards[workspace.agendaCursor]
|
|
||||||
if (card) guard(() => workspace.finishCard(card))
|
|
||||||
},
|
|
||||||
due: () => {
|
due: () => {
|
||||||
if (workspace.agendaCards[workspace.agendaCursor]) line(':due ')
|
if (workspace.agendaCards[workspace.agendaCursor]) line(':due ')
|
||||||
},
|
},
|
||||||
line: () => line(':'),
|
line: () => line(':'),
|
||||||
sendLeft: () => sendAgendaCard(-1),
|
sendLeft: () => guard(() => workspace.sendCard(-1)),
|
||||||
sendRight: () => sendAgendaCard(1),
|
sendRight: () => guard(() => workspace.sendCard(1)),
|
||||||
today: () => void runCommand('today'),
|
today: () => void runCommand('today'),
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -520,8 +546,3 @@ function goToSpace(index: number) {
|
|||||||
workspace.closeSheet()
|
workspace.closeSheet()
|
||||||
guard(() => workspace.enterSpace(entry.space.id))
|
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
@@ -53,6 +53,8 @@ export interface WorkspaceNode extends NodeSummary {
|
|||||||
path: Crumb[]
|
path: Crumb[]
|
||||||
/** How many documents link here. */
|
/** How many documents link here. */
|
||||||
backlinks: number
|
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. */
|
/** Where each `[[title]]` in the body leads, as an /n/ path; a title that leads nowhere is missing. */
|
||||||
wikilinks: Record<string, string>
|
wikilinks: Record<string, string>
|
||||||
}
|
}
|
||||||
@@ -191,12 +193,16 @@ export interface Moved extends Card {
|
|||||||
again: Card | null
|
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 {
|
export interface AgendaCard extends Card {
|
||||||
space_id: number
|
space_id: number
|
||||||
space_slug: string
|
space_slug: string
|
||||||
list_name: string
|
list_name: string
|
||||||
role: 'inbox' | 'doing'
|
/** The agenda only ever has the first two. */
|
||||||
|
role: 'inbox' | 'doing' | 'done'
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Agenda {
|
export interface Agenda {
|
||||||
@@ -456,6 +462,11 @@ export const api = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
getNode: (id: number) => request<WorkspaceNode>(`/nodes/${id}`),
|
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. */
|
/** `work/einarbeiten/01-was-ist-eebus` — the node behind a `/n/...` address. */
|
||||||
resolvePath: (path: string) => request<WorkspaceNode>(`/resolve?path=${encodeURIComponent(path)}`),
|
resolvePath: (path: string) => request<WorkspaceNode>(`/resolve?path=${encodeURIComponent(path)}`),
|
||||||
|
|||||||
+26
-31
@@ -78,7 +78,8 @@ const closeFocused = () => {
|
|||||||
/**
|
/**
|
||||||
* The info sheet over the open document, at `section` or where it was last
|
* 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
|
* 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) {
|
export async function openInfo(section?: InfoSection) {
|
||||||
if (!requireOpen()) return
|
if (!requireOpen()) return
|
||||||
@@ -90,6 +91,8 @@ export async function openInfo(section?: InfoSection) {
|
|||||||
// of the same text at once would race.
|
// of the same text at once would race.
|
||||||
await workspace.loadHistory()
|
await workspace.loadHistory()
|
||||||
await workspace.loadBacklinks()
|
await workspace.loadBacklinks()
|
||||||
|
// Picked afresh each time, from the first: the open card leads.
|
||||||
|
workspace.cardsCursor = 0
|
||||||
if (section) workspace.infoSection = section
|
if (section) workspace.infoSection = section
|
||||||
workspace.openSheet('info')
|
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. */
|
/** The board's cursor, or the card turned over beside it, has the keys. */
|
||||||
const onBoard = () => workspace.focus === 'board' || workspace.focus === 'card'
|
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 = () => {
|
const requireOpen = () => {
|
||||||
if (!workspace.open) {
|
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`. */
|
/** Move the cursor and follow it, so `:next` reads as well as `j` then `Enter`. */
|
||||||
const jumpTo = async (index: number) => {
|
const jumpTo = async (index: number) => {
|
||||||
if (workspace.nodes.length === 0) return workspace.say('nothing here', 'error')
|
if (workspace.rows === 0) return workspace.say('nothing here', 'error')
|
||||||
workspace.cursor = Math.min(Math.max(index, 0), workspace.nodes.length - 1)
|
workspace.cursor = Math.min(Math.max(index, 0), workspace.rows - 1)
|
||||||
await workspace.guard(() => workspace.openSelected())
|
await workspace.guard(() => workspace.openSelected())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -509,19 +510,16 @@ export const commands: CommandSpec[] = [
|
|||||||
name: 'due',
|
name: 'due',
|
||||||
args: '<when|->',
|
args: '<when|->',
|
||||||
group: 'board',
|
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'],
|
complete: () => ['today', 'tm', 'fri', '+3d'],
|
||||||
run: async (arg) => {
|
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()
|
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)
|
const at = parseDue(target)
|
||||||
if (at === null) return workspace.say(`not a date: ${target} — try fri, +3d or 2026-09-30`, 'error')
|
if (at === null) return workspace.say(`not a date: ${target} — try fri, +3d or 2026-09-30`, 'error')
|
||||||
await workspace.guard(() => set(at))
|
await workspace.guard(async () => {
|
||||||
workspace.say(`due ${formatDay(at)}`)
|
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',
|
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', '-'],
|
complete: () => ['daily', 'weekly', 'monthly', 'yearly', '+2w', '-'],
|
||||||
run: async (arg) => {
|
run: async (arg) => {
|
||||||
const agenda = workspace.focus === 'agenda'
|
|
||||||
if (!cardFocused()) return workspace.say('no card in focus — :board', 'error')
|
|
||||||
const rule = arg.trim()
|
const rule = arg.trim()
|
||||||
const repeat = rule === '-' || rule === '' ? null : rule
|
const repeat = rule === '-' || rule === '' ? null : rule
|
||||||
await workspace.guard(async () => {
|
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`.
|
// 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`)
|
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',
|
name: 'done',
|
||||||
group: 'board',
|
group: 'board',
|
||||||
help: 'move the selected card into the done list, or back out to the inbox',
|
help: 'move the card in focus into the done list, or back out to the inbox',
|
||||||
run: () => {
|
run: () => workspace.guard(() => workspace.toggleDone()),
|
||||||
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())
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'scratch',
|
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',
|
help: 'put a label on the card, or take it off when it has it — each name keeps its colour everywhere',
|
||||||
complete: () => workspace.boardLabels,
|
complete: () => workspace.boardLabels,
|
||||||
run: async (arg) => {
|
run: async (arg) => {
|
||||||
if (!onBoard()) return workspace.say('no card in focus — :board', 'error')
|
const card = workspace.focusedCard
|
||||||
if (!arg.trim()) return workspace.say(workspace.card?.labels.join(' · ') || 'no labels — :label <name> adds one')
|
if (!arg.trim() && card) return workspace.say(card.labels.join(' · ') || 'no labels — :label <name> adds one')
|
||||||
await workspace.guard(() => workspace.toggleLabel(arg))
|
await workspace.guard(() => workspace.toggleLabel(arg))
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -840,7 +829,7 @@ export const commands: CommandSpec[] = [
|
|||||||
name: 'last',
|
name: 'last',
|
||||||
group: 'documents',
|
group: 'documents',
|
||||||
help: 'open the last node in the list',
|
help: 'open the last node in the list',
|
||||||
run: () => jumpTo(workspace.nodes.length - 1),
|
run: () => jumpTo(workspace.rows - 1),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'random',
|
name: 'random',
|
||||||
@@ -953,6 +942,12 @@ export const commands: CommandSpec[] = [
|
|||||||
await openInfo('backlinks')
|
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',
|
name: 'trash',
|
||||||
args: '[purge]',
|
args: '[purge]',
|
||||||
@@ -1126,13 +1121,13 @@ export const commands: CommandSpec[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'info',
|
name: 'info',
|
||||||
args: '[outline|history|backlinks|share]',
|
args: '[outline|history|backlinks|cards|share]',
|
||||||
group: 'documents',
|
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,
|
complete: () => infoSections,
|
||||||
run: (arg) => {
|
run: (arg) => {
|
||||||
const section = arg.trim()
|
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)
|
return openInfo((section || undefined) as InfoSection | undefined)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
+17
-2
@@ -23,6 +23,7 @@ export type Context =
|
|||||||
| 'media'
|
| 'media'
|
||||||
| 'trash'
|
| 'trash'
|
||||||
| 'backlinks'
|
| 'backlinks'
|
||||||
|
| 'cards'
|
||||||
| 'history'
|
| 'history'
|
||||||
| 'share'
|
| 'share'
|
||||||
| 'card'
|
| '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: ['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: 'nextHeading', label: ']] / [[', help: 'next / previous heading of the document — in the editor too' },
|
||||||
{ context: 'tree', keys: ['[['], action: 'previousHeading' },
|
{ 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: ['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', 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' },
|
{ 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: '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: ['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: '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: ['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: ['/'], 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' },
|
{ 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: ['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: ['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: ['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: '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: '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' },
|
{ 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: ['G'], action: 'bottom' },
|
||||||
{ context: 'backlinks', keys: ['Enter'], action: 'open', hint: 'open' },
|
{ 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: ['j', 'ArrowDown'], action: 'down', caps: ['j', 'k'], hint: 'earlier / later' },
|
||||||
{ context: 'history', keys: ['k', 'ArrowUp'], action: 'up' },
|
{ context: 'history', keys: ['k', 'ArrowUp'], action: 'up' },
|
||||||
{ context: 'history', keys: ['G'], action: 'bottom' },
|
{ context: 'history', keys: ['G'], action: 'bottom' },
|
||||||
|
|||||||
+153
-79
@@ -7,6 +7,7 @@ import {
|
|||||||
type AgendaCard,
|
type AgendaCard,
|
||||||
type Backlink,
|
type Backlink,
|
||||||
type Board,
|
type Board,
|
||||||
|
type BoardList,
|
||||||
type Card,
|
type Card,
|
||||||
type Crumb,
|
type Crumb,
|
||||||
type DiaryEntry,
|
type DiaryEntry,
|
||||||
@@ -35,7 +36,7 @@ export type View = Exclude<TileKind, 'node'>
|
|||||||
/** What floats over the tiles; one at a time. */
|
/** What floats over the tiles; one at a time. */
|
||||||
export type Sheet = 'help' | 'media' | 'overview' | 'actions' | 'verify' | 'info' | 'trash'
|
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. */
|
/** 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
|
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. */
|
/** Children of the current folder, or the results of a search. */
|
||||||
nodes = $state<NodeSummary[]>([])
|
nodes = $state<NodeSummary[]>([])
|
||||||
|
/** The cards a search found, listed after the nodes. */
|
||||||
|
foundCards = $state<AgendaCard[]>([])
|
||||||
cursor = $state(0)
|
cursor = $state(0)
|
||||||
query = $state('')
|
query = $state('')
|
||||||
|
|
||||||
@@ -407,6 +410,8 @@ class Workspace {
|
|||||||
/** `:backlinks`: the documents that link to the open one. */
|
/** `:backlinks`: the documents that link to the open one. */
|
||||||
backlinks = $state<Backlink[]>([])
|
backlinks = $state<Backlink[]>([])
|
||||||
backlinksCursor = $state(0)
|
backlinksCursor = $state(0)
|
||||||
|
/** The info sheet's cards: where its cursor is in the open document's. */
|
||||||
|
cardsCursor = $state(0)
|
||||||
overview = $state<SpaceOverview[]>([])
|
overview = $state<SpaceOverview[]>([])
|
||||||
overviewCursor = $state(0)
|
overviewCursor = $state(0)
|
||||||
|
|
||||||
@@ -434,6 +439,16 @@ class Workspace {
|
|||||||
return this.nodes[this.cursor]
|
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,
|
* 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
|
* 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]
|
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
|
* 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
|
* 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. */
|
/** `keep` follows a node that just arrived, instead of holding the cursor. */
|
||||||
async refresh(keep?: number) {
|
async refresh(keep?: number) {
|
||||||
const keepId = keep ?? this.selected?.id
|
const keepId = keep ?? this.selected?.id
|
||||||
this.nodes = this.query
|
if (this.query) {
|
||||||
? await api.listNodes({ q: this.query, space: this.globalSearch ? undefined : (this.spaceId ?? undefined) })
|
const space = this.#searchSpace
|
||||||
: await api.listNodes({ parent: this.parentId ?? undefined })
|
;[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)
|
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) {
|
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
|
* `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
|
* beside the results so the next `n` goes on from there. A folder or a card
|
||||||
* highlighted: going into it would end the search. Past the last result it
|
* is only highlighted: going to it would end the search. Past the last
|
||||||
* starts again at the first, as vim's `wrapscan` does.
|
* result it starts again at the first, as vim's `wrapscan` does.
|
||||||
*/
|
*/
|
||||||
async nextResult(delta: 1 | -1) {
|
async nextResult(delta: 1 | -1) {
|
||||||
if (!this.query) return this.say('no search — / starts one', 'error')
|
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')
|
if (count === 0) return this.say(`no results for "${this.query}"`, 'error')
|
||||||
const next = this.cursor + delta
|
const next = this.cursor + delta
|
||||||
if (next >= count) this.say('search hit BOTTOM, continuing at TOP')
|
if (next >= count) this.say('search hit BOTTOM, continuing at TOP')
|
||||||
else if (next < 0) this.say('search hit TOP, continuing at BOTTOM')
|
else if (next < 0) this.say('search hit TOP, continuing at BOTTOM')
|
||||||
this.cursor = (next + count) % count
|
this.cursor = (next + count) % count
|
||||||
const node = this.nodes[this.cursor]
|
const node = this.selected
|
||||||
if (!isContainer(node)) await this.openNode(node.id)
|
if (node && !isContainer(node)) await this.openNode(node.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
move(delta: number) {
|
move(delta: number) {
|
||||||
if (this.nodes.length === 0) return
|
if (this.rows === 0) return
|
||||||
this.cursor = Math.min(Math.max(this.cursor + delta, 0), this.nodes.length - 1)
|
this.cursor = Math.min(Math.max(this.cursor + delta, 0), this.rows - 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
async openNode(id: number, edit = false) {
|
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. */
|
/** What Enter does: descend into a folder, open a document or a file. */
|
||||||
async openSelected(edit = false) {
|
async openSelected(edit = false) {
|
||||||
|
const found = this.foundCard
|
||||||
|
if (found) return this.turnTo(found)
|
||||||
const selected = this.selected
|
const selected = this.selected
|
||||||
if (!selected) return
|
if (!selected) return
|
||||||
// A match from anywhere opens beside the results, as one from this space
|
// A match from anywhere opens beside the results, as one from this space
|
||||||
@@ -1470,44 +1528,13 @@ class Workspace {
|
|||||||
await this.showView('board')
|
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
|
* Enter on a card a search found, or one picked in a document's info
|
||||||
* is in, without going to the board — `L` on the last list but one is done.
|
* sheet: to its board, turned over beside it.
|
||||||
*/
|
*/
|
||||||
async sendAgendaCard(card: AgendaCard, delta: number) {
|
async turnTo(card: AgendaCard) {
|
||||||
const lists = (await api.board(card.space_id)).lists
|
await this.goToCard(card)
|
||||||
const from = lists.findIndex((list) => list.id === card.list_id)
|
await this.turnCard()
|
||||||
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 loadOverview() {
|
async loadOverview() {
|
||||||
@@ -1721,25 +1748,75 @@ class Workspace {
|
|||||||
await this.reloadBoard(this.boardSpaceId, card.id)
|
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) {
|
* `H`/`L`: the same card, one list over — the move a board is really for.
|
||||||
await this.sendCardTo(this.listCursor + delta)
|
* 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
|
* 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.
|
* there and clears the stamp as it leaves, so moving is all there is to it.
|
||||||
*/
|
*/
|
||||||
async sendCardTo(index: number) {
|
async #sendTo(card: Card, lists: BoardList[], index: number) {
|
||||||
const card = this.card
|
|
||||||
const lists = this.board?.lists ?? []
|
|
||||||
const target = lists[index]
|
const target = lists[index]
|
||||||
if (!card || !target || index === this.listCursor) return
|
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, target.cards.length)
|
const moved = await api.moveCard(card.id, target.id)
|
||||||
await this.reloadBoard(this.boardSpaceId, card.id)
|
await this.#cardChanged(card)
|
||||||
this.say(index === lists.length - 1 ? doneSaid(moved) : `"${card.title}" → ${target.name}`)
|
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
|
* 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
|
* 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')
|
if (!list) return this.say('no list to put it on', 'error')
|
||||||
const card = await api.createCard(list.id, { title, node_id: nodeId })
|
const card = await api.createCard(list.id, { title, node_id: nodeId })
|
||||||
await this.reloadBoard(this.boardSpaceId, card.id)
|
await this.reloadBoard(this.boardSpaceId, card.id)
|
||||||
|
await this.#reloadCardsAbout()
|
||||||
return this.say(nodeId === null ? `carded "${card.title}"` : `carded "${card.title}" → #${nodeId}`)
|
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')
|
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 })
|
const card = await api.createCard(inbox.id, { title, node_id: nodeId })
|
||||||
await this.loadAgenda()
|
await this.loadAgenda()
|
||||||
|
await this.#reloadCardsAbout()
|
||||||
this.say(`carded "${card.title}" → ${space.slug}/${inbox.name}`)
|
this.say(`carded "${card.title}" → ${space.slug}/${inbox.name}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
async patchCard(patch: Parameters<typeof api.updateCard>[1]) {
|
/** A change to the focused card — or to `card`, when it is known which. */
|
||||||
const card = this.card
|
async patchCard(patch: Parameters<typeof api.updateCard>[1], card = this.#needCard()) {
|
||||||
if (!card) return this.say('no card selected', 'error')
|
if (!card) return
|
||||||
const updated = await api.updateCard(card.id, patch)
|
const updated = await api.updateCard(card.id, patch)
|
||||||
await this.reloadBoard(this.boardSpaceId, card.id)
|
await this.#cardChanged(card)
|
||||||
return updated
|
return updated
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Space: into the done list, or back out of it to the inbox. */
|
/** Space: into the done list, or back out of it to the inbox. */
|
||||||
async toggleDone() {
|
async toggleDone(card = this.#needCard()) {
|
||||||
const card = this.card
|
if (!card) return
|
||||||
const lists = this.board?.lists ?? []
|
const done = card.done_at === null
|
||||||
if (!card) return this.say('no card selected', 'error')
|
const moved = await api.updateCard(card.id, { done })
|
||||||
if (lists.length < 2) return this.say('a board needs two lists for one to mean done', 'error')
|
await this.#cardChanged(card)
|
||||||
const last = lists.length - 1
|
this.say(done ? doneSaid(moved) : `reopened: ${card.title}`)
|
||||||
if (this.listCursor === last) {
|
|
||||||
await this.sendCardTo(0)
|
|
||||||
this.say(`reopened: ${card.title}`)
|
|
||||||
} else {
|
|
||||||
await this.sendCardTo(last)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteCard() {
|
async deleteCard() {
|
||||||
@@ -1972,7 +2045,8 @@ class Workspace {
|
|||||||
async saveDescription() {
|
async saveDescription() {
|
||||||
const card = this.card
|
const card = this.card
|
||||||
if (!card || !this.describing) return
|
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.descriptionDirty = false
|
||||||
this.say(`"${card.title}" described`)
|
this.say(`"${card.title}" described`)
|
||||||
}
|
}
|
||||||
@@ -2003,7 +2077,7 @@ class Workspace {
|
|||||||
|
|
||||||
/** The checklist written back whole, with the cursor on step `at`. */
|
/** The checklist written back whole, with the cursor on step `at`. */
|
||||||
async writeChecklist(checklist: Task[], at = this.taskCursor) {
|
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))
|
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. */
|
/** `:label work`: on the card, or off it when it is on already. */
|
||||||
toggleLabel(name: string) {
|
toggleLabel(name: string) {
|
||||||
return this.inTurn(async () => {
|
return this.inTurn(async () => {
|
||||||
const card = this.card
|
const card = this.#needCard()
|
||||||
if (!card) return this.say('no card selected', 'error')
|
if (!card) return
|
||||||
const label = name.trim().toLowerCase()
|
const label = name.trim().toLowerCase()
|
||||||
const on = card.labels.includes(label)
|
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`)
|
this.say(on ? `${label} off` : `${label} on`)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user