mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
scratch: the scratchpad is the inbox, with ways out and a search
A note now leaves the scratchpad by becoming something else: `K` keeps it as a document, as before; `C` makes it a card where `:card` would put one, the first line its title; `A` opens `:append` with the document on screen filled in, Tab completing any other title, and puts the note at its end. `/api/capture` takes `to: "note"` and `to: "document"` (named as a link names it), and `note`, the scratch note the text came from, dropped once the text has landed. Appending keeps the revision before it and indexes links and media, as a save does. Migration 0028 adds `scratch_fts`; `GET /api/scratch?q=` searches it, and `/` lists note hits under "notes" after the cards, Enter lowering the drawer at the note. The `c` capture line cycles its target with Tab: a card, a note, or a timed line in today's entry, which `:today <text>` now writes.
This commit is contained in:
1 parent
ccc91028e7
commit
2233fa41a6
20 files changed
+561
-84
No files matched your search
@@ -148,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 — reading a document, a card about it |
|
| `c` | capture a card into this space's inbox without leaving where you are — reading a document, a card about it; `Tab` on the line makes it a note, or a line in today's entry |
|
||||||
| `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 |
|
||||||
@@ -264,7 +264,8 @@ done cards older than a week fold away until `z` shows them.
|
|||||||
|
|
||||||
`c` captures from anywhere: `:card <title>` outside the board puts the card in
|
`c` captures from anywhere: `:card <title>` outside the board puts the card in
|
||||||
this space's inbox (or the first space that has a board) and leaves you where
|
this space's inbox (or the first space that has a board) and leaves you where
|
||||||
you were. On the board, `o` or `c` puts a card on the column under the cursor.
|
you were. `Tab` on that line makes it a note on the scratchpad instead, or a
|
||||||
|
timed line in today's diary entry (`:today <text>`). On the board, `o` or `c` puts a card on the column under the cursor.
|
||||||
|
|
||||||
| key | action |
|
| key | action |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
@@ -375,7 +376,9 @@ named by `"space": "<slug>"`, or else of the first space that has a board,
|
|||||||
the same as `:card` outside the board. The first line is the card's title and
|
the same as `:card` outside the board. The first line is the card's title and
|
||||||
the rest its notes. `"to": "diary"` adds `- 14:05 call the bank` to the end of
|
the rest its notes. `"to": "diary"` adds `- 14:05 call the bank` to the end of
|
||||||
today's entry, in `WORKSPACE_TIMEZONE`, and starts the entry when there is
|
today's entry, in `WORKSPACE_TIMEZONE`, and starts the entry when there is
|
||||||
none yet, from `templates/day` when the diary has one. The answer says where it went: `{"to":"card","id":7,"where":"work/backlog"}`.
|
none yet, from `templates/day` when the diary has one. `"to": "note"` puts it
|
||||||
|
on the scratchpad, and `"to": "document"` at the end of the document named by
|
||||||
|
`"document"`, as a link names it: `Plan`, `[[Plan]]` or `/n/work/plan`. The answer says where it went: `{"to":"card","id":7,"where":"work/backlog"}`.
|
||||||
|
|
||||||
A token opens this one route and nothing else — it reads nothing, not even the
|
A token opens this one route and nothing else — it reads nothing, not even the
|
||||||
list of spaces — and only its SHA-256 is kept, the way a share key is. Wrong
|
list of spaces — and only its SHA-256 is kept, the way a share key is. Wrong
|
||||||
@@ -875,7 +878,7 @@ sealed or not.
|
|||||||
| `POST` | `/api/diary/day` | `{ day? }`: the entry of `2026-10-22`, or of today in `WORKSPACE_TIMEZONE`, started from `templates/day` when there is none; answers `{ id, created }` |
|
| `POST` | `/api/diary/day` | `{ day? }`: the entry of `2026-10-22`, or of today in `WORKSPACE_TIMEZONE`, started from `templates/day` when there is none; answers `{ id, created }` |
|
||||||
| `GET` | `/api/diary/around/{id}` | the entries before and after a diary entry, by date |
|
| `GET` | `/api/diary/around/{id}` | the entries before and after a diary entry, by date |
|
||||||
| `GET` | `/api/nodes/{id}/templates` | the templates of the space the node is in |
|
| `GET` | `/api/nodes/{id}/templates` | the templates of the space the node is in |
|
||||||
| `POST` | `/api/capture` | `{ text, to: "card" \| "diary", space? }`, with a session or `Authorization: Bearer` a token |
|
| `POST` | `/api/capture` | `{ text, to: "card" \| "diary" \| "note" \| "document", space?, document?, note? }`, with a session or `Authorization: Bearer` a token; `note` is a scratch note the text came from, dropped once it has landed |
|
||||||
| `GET`/`POST` | `/api/tokens` | the capture tokens / make one, `{ name }`, answered with the token this once |
|
| `GET`/`POST` | `/api/tokens` | the capture tokens / make one, `{ name }`, answered with the token this once |
|
||||||
| `DELETE` | `/api/tokens/{id}` | remove a capture token |
|
| `DELETE` | `/api/tokens/{id}` | remove a capture token |
|
||||||
| `POST` | `/api/spaces/{id}/lists` | add a list |
|
| `POST` | `/api/spaces/{id}/lists` | add a list |
|
||||||
@@ -884,7 +887,7 @@ sealed or not.
|
|||||||
| `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` | `/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, or with `?q=` those a search finds / 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 |
|
||||||
| `POST` | `/api/nodes/move` | `{ ids, parent_id }`: several into one folder, all or none |
|
| `POST` | `/api/nodes/move` | `{ ids, parent_id }`: several into one folder, all or none |
|
||||||
|
|||||||
+2
-2
@@ -25,7 +25,7 @@ sealed or not.
|
|||||||
| `POST` | `/api/diary/day` | `{ day? }`: the entry of `2026-10-22`, or of today in `WORKSPACE_TIMEZONE`, started from `templates/day` when there is none; answers `{ id, created }` |
|
| `POST` | `/api/diary/day` | `{ day? }`: the entry of `2026-10-22`, or of today in `WORKSPACE_TIMEZONE`, started from `templates/day` when there is none; answers `{ id, created }` |
|
||||||
| `GET` | `/api/diary/around/{id}` | the entries before and after a diary entry, by date |
|
| `GET` | `/api/diary/around/{id}` | the entries before and after a diary entry, by date |
|
||||||
| `GET` | `/api/nodes/{id}/templates` | the templates of the space the node is in |
|
| `GET` | `/api/nodes/{id}/templates` | the templates of the space the node is in |
|
||||||
| `POST` | `/api/capture` | `{ text, to: "card" \| "diary", space? }`, with a session or `Authorization: Bearer` a token |
|
| `POST` | `/api/capture` | `{ text, to: "card" \| "diary" \| "note" \| "document", space?, document?, note? }`, with a session or `Authorization: Bearer` a token; `note` is a scratch note the text came from, dropped once it has landed |
|
||||||
| `GET`/`POST` | `/api/tokens` | the capture tokens / make one, `{ name }`, answered with the token this once |
|
| `GET`/`POST` | `/api/tokens` | the capture tokens / make one, `{ name }`, answered with the token this once |
|
||||||
| `DELETE` | `/api/tokens/{id}` | remove a capture token |
|
| `DELETE` | `/api/tokens/{id}` | remove a capture token |
|
||||||
| `POST` | `/api/spaces/{id}/lists` | add a list |
|
| `POST` | `/api/spaces/{id}/lists` | add a list |
|
||||||
@@ -34,7 +34,7 @@ sealed or not.
|
|||||||
| `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` | `/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, or with `?q=` those a search finds / 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 |
|
||||||
| `POST` | `/api/nodes/move` | `{ ids, parent_id }`: several into one folder, all or none |
|
| `POST` | `/api/nodes/move` | `{ ids, parent_id }`: several into one folder, all or none |
|
||||||
|
|||||||
@@ -27,14 +27,20 @@ curl -H "Authorization: Bearer nwc_…" -H 'content-type: application/json' \
|
|||||||
| field | meaning |
|
| field | meaning |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `text` | what to write down; for a card, the first line is the title and the rest its notes |
|
| `text` | what to write down; for a card, the first line is the title and the rest its notes |
|
||||||
| `to` | `"card"` (the default) or `"diary"` |
|
| `to` | `"card"` (the default), `"diary"`, `"note"` or `"document"` |
|
||||||
| `space` | for a card, the slug of the space whose inbox it goes in |
|
| `space` | for a card, the slug of the space whose inbox it goes in; for a document, the space its title is looked for in first |
|
||||||
|
| `document` | for `"document"`, which one, as a link names it: `Plan`, `[[Plan]]` or `/n/work/plan` |
|
||||||
|
| `note` | the id of the scratch note the text came from; it is dropped once the text has landed |
|
||||||
|
|
||||||
- `"to": "card"` puts a card in the inbox of the space named by `space`, or else
|
- `"to": "card"` puts a card in the inbox of the space named by `space`, or else
|
||||||
of the first space that has a board.
|
of the first space that has a board.
|
||||||
- `"to": "diary"` adds `- 14:05 call the bank` to the end of today's entry, in
|
- `"to": "diary"` adds `- 14:05 call the bank` to the end of today's entry, in
|
||||||
`WORKSPACE_TIMEZONE`, and starts the entry from `templates/day` when there is
|
`WORKSPACE_TIMEZONE`, and starts the entry from `templates/day` when there is
|
||||||
none yet.
|
none yet.
|
||||||
|
- `"to": "note"` puts it on the scratchpad, for when it is not clear yet where
|
||||||
|
it belongs.
|
||||||
|
- `"to": "document"` adds it as a paragraph at the end of a document. The text
|
||||||
|
it replaces is kept in the document's history, as a save keeps it.
|
||||||
|
|
||||||
The answer says where it went: `{"to":"card","id":7,"where":"work/backlog"}`.
|
The answer says where it went: `{"to":"card","id":7,"where":"work/backlog"}`.
|
||||||
|
|
||||||
|
|||||||
@@ -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 — reading a document, a card about it |
|
| `c` | capture a card into this space's inbox, without leaving here — reading a document, a card about it; Tab on the line makes it a note, or a line in today's entry |
|
||||||
| `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 |
|
||||||
@@ -127,6 +127,8 @@ The first list is the inbox, the last list is done.
|
|||||||
| `o` | a new note, written straight away — it saves itself |
|
| `o` | a new note, written straight away — it saves itself |
|
||||||
| `Enter · e · i` | write the note — Esc in normal mode, or :q, comes back to the notes |
|
| `Enter · e · i` | write the note — Esc in normal mode, or :q, comes back to the notes |
|
||||||
| `K` | keep the note: a document in the folder being browsed, titled by its first line — :keep <title> names it |
|
| `K` | keep the note: a document in the folder being browsed, titled by its first line — :keep <title> names it |
|
||||||
|
| `C` | make the note a card where :card puts one — the first line its title, the rest its description |
|
||||||
|
| `A` | put the note at the end of a document — :append, with the one on screen filled in; Tab completes a title |
|
||||||
| `dd` | drop the note (asks y/N) |
|
| `dd` | drop the note (asks y/N) |
|
||||||
| `q · Esc · Q` | raise the scratchpad |
|
| `q · Esc · Q` | raise the scratchpad |
|
||||||
|
|
||||||
@@ -173,10 +175,11 @@ do: `:w!`, `:space! <name>`, `:import!`.
|
|||||||
| command | what it does |
|
| command | what it does |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `:scratch, :sc [text]` | the scratchpad, lowered over whatever is on screen — Q does the same; with text, a note on it without leaving |
|
| `:scratch, :sc [text]` | the scratchpad, lowered over whatever is on screen — Q does the same; with text, a note on it without leaving |
|
||||||
|
| `:append [document]` | put the scratch note at the end of a document, by its title — Tab completes it — or the one on screen, and take it off the scratchpad |
|
||||||
| `:keep [title]` | make the scratch note a document in the folder being browsed — titled by its first line unless given one — and take it off the scratchpad |
|
| `:keep [title]` | make the scratch note a document in the folder being browsed — titled by its first line unless given one — and take it off the scratchpad |
|
||||||
| `:new, :n, :o [template] [yyyy-mm-dd] [name]` | start a new document here — from one of the space's templates, dated, named; in the diary from templates/day |
|
| `:new, :n, :o [template] [yyyy-mm-dd] [name]` | start a new document here — from one of the space's templates, dated, named; in the diary from templates/day |
|
||||||
| `:cal, :calendar` | the diary by month — h/l a day, j/k a week, H/L a month, Enter opens the day or starts it |
|
| `:cal, :calendar` | the diary by month — h/l a day, j/k a week, H/L a month, Enter opens the day or starts it |
|
||||||
| `:today` | open today's diary entry, starting one if there is none yet |
|
| `:today [text]` | open today's diary entry, starting one if there is none yet; with text, a timed line at its end without going there |
|
||||||
| `:next` | open the next node down the list |
|
| `:next` | open the next node down the list |
|
||||||
| `:prev` | open the previous node |
|
| `:prev` | open the previous node |
|
||||||
| `:first` | open the first node in the list |
|
| `:first` | open the first node in the list |
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ previous result beside the list, and `:noh` clears the search.
|
|||||||
Cards are searched too, by their title, description and checklist steps. They
|
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
|
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.
|
on. `Enter` on one turns it over on its board; `n` and `N` only light it up.
|
||||||
|
Notes on the scratchpad come last, under `notes`, whatever space is searched;
|
||||||
|
`Enter` lowers the scratchpad at the note.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -84,7 +84,10 @@ More in [Writing](Writing.md).
|
|||||||
|
|
||||||
In a meeting someone asks you to renew a certificate. Press `c`, type
|
In a meeting someone asks you to renew a certificate. Press `c`, type
|
||||||
`Renew the gateway certificate`, and `Enter`. The card is in this space's
|
`Renew the gateway certificate`, and `Enter`. The card is in this space's
|
||||||
inbox, and you have not left the document you were reading.
|
inbox, and you have not left the document you were reading. Not a task? `Tab`
|
||||||
|
on the same line makes it a note on the scratchpad, and `Tab` again a timed line
|
||||||
|
in today's diary entry. A note waits there until `K`, `C` or `A` turns it into a
|
||||||
|
document, a card, or more of a document.
|
||||||
|
|
||||||
Press `b` to see the **board**, in a tile beside the document. A digit there
|
Press `b` to see the **board**, in a tile beside the document. A digit there
|
||||||
shows the board of another space.
|
shows the board of another space.
|
||||||
|
|||||||
@@ -67,6 +67,8 @@ a body and the time it was last touched, newest on top.
|
|||||||
| `o` | a new note, written straight away |
|
| `o` | a new note, written straight away |
|
||||||
| `Enter`, `e`, `i` | write the note under the cursor |
|
| `Enter`, `e`, `i` | write the note under the cursor |
|
||||||
| `K` | keep it: a document in the folder being browsed (`:keep [title]`) |
|
| `K` | keep it: a document in the folder being browsed (`:keep [title]`) |
|
||||||
|
| `C` | make it a card, where `:card` puts one: the first line its title, the rest its description |
|
||||||
|
| `A` | put it at the end of a document (`:append [document]`) |
|
||||||
| `dd` | drop the note (asks y/N) |
|
| `dd` | drop the note (asks y/N) |
|
||||||
| `Q`, `q`, `Esc` | raise the scratchpad |
|
| `Q`, `q`, `Esc` | raise the scratchpad |
|
||||||
|
|
||||||
@@ -81,6 +83,15 @@ takes it off the scratchpad. Its first line is the title — `# Plan` or plain
|
|||||||
`Plan` alike — and the rest the body; `:keep <title>` names it instead and
|
`Plan` alike — and the rest the body; `:keep <title>` names it instead and
|
||||||
keeps every line. The document opens, and the drawer goes up.
|
keeps every line. The document opens, and the drawer goes up.
|
||||||
|
|
||||||
|
`C` makes the note a card on the list under the board's cursor, or in this
|
||||||
|
space's inbox. `A` opens `:append` with the document on screen filled in;
|
||||||
|
change it to any document's title — `Tab` completes it — and `Enter` puts the
|
||||||
|
note at its end, as a paragraph of its own. The document's text before is kept
|
||||||
|
in its history. Either way the note leaves the scratchpad, as it does with `K`.
|
||||||
|
|
||||||
|
`/` finds notes too, listed under `notes` after the documents and the cards;
|
||||||
|
`Enter` on one lowers the scratchpad with it under the cursor.
|
||||||
|
|
||||||
The notes are in the backup, and an export of the whole workspace carries
|
The notes are in the backup, and an export of the whole workspace carries
|
||||||
them in `.workspace.json`, so `:import! /` puts them back.
|
them in `.workspace.json`, so `:import! /` puts them back.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
-- The scratchpad is where things are written down before it is clear where
|
||||||
|
-- they belong, which makes it the first place to look for something half
|
||||||
|
-- remembered. Search finds a note the way it finds a document or a card.
|
||||||
|
CREATE VIRTUAL TABLE scratch_fts USING fts5 (body, tokenize = 'unicode61');
|
||||||
|
|
||||||
|
CREATE TRIGGER scratch_ai AFTER INSERT ON scratch_notes BEGIN
|
||||||
|
INSERT INTO scratch_fts (rowid, body) VALUES (new.id, new.body);
|
||||||
|
END;
|
||||||
|
CREATE TRIGGER scratch_ad AFTER DELETE ON scratch_notes BEGIN
|
||||||
|
DELETE FROM scratch_fts WHERE rowid = old.id;
|
||||||
|
END;
|
||||||
|
CREATE TRIGGER scratch_au AFTER UPDATE OF body ON scratch_notes BEGIN
|
||||||
|
DELETE FROM scratch_fts WHERE rowid = old.id;
|
||||||
|
INSERT INTO scratch_fts (rowid, body) VALUES (new.id, new.body);
|
||||||
|
END;
|
||||||
|
|
||||||
|
-- The notes written before the triggers existed.
|
||||||
|
INSERT INTO scratch_fts (rowid, body) SELECT id, body FROM scratch_notes;
|
||||||
+195
-18
@@ -7,14 +7,16 @@ use axum::{extract::State, Json};
|
|||||||
use chrono::{TimeZone, Utc};
|
use chrono::{TimeZone, Utc};
|
||||||
use chrono_tz::Tz;
|
use chrono_tz::Tz;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::SqlitePool;
|
use sqlx::{SqliteConnection, SqlitePool};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
auth::Capturer,
|
auth::Capturer,
|
||||||
error::{AppError, AppResult},
|
error::{AppError, AppResult},
|
||||||
routes::{
|
routes::{
|
||||||
boards::{self, CardInput},
|
boards::{self, CardInput},
|
||||||
diary, links, media, nodes, revisions,
|
diary, links, media,
|
||||||
|
nodes::{self, DOCUMENT},
|
||||||
|
revisions, scratch, trash,
|
||||||
},
|
},
|
||||||
state::AppState,
|
state::AppState,
|
||||||
};
|
};
|
||||||
@@ -27,6 +29,10 @@ pub enum To {
|
|||||||
Card,
|
Card,
|
||||||
/// A line in today's diary entry: something that happened.
|
/// A line in today's diary entry: something that happened.
|
||||||
Diary,
|
Diary,
|
||||||
|
/// A note on the scratchpad: not clear yet where it belongs.
|
||||||
|
Note,
|
||||||
|
/// A paragraph at the end of a document: more of what it is about.
|
||||||
|
Document,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -34,18 +40,28 @@ pub struct CaptureInput {
|
|||||||
pub text: String,
|
pub text: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub to: To,
|
pub to: To,
|
||||||
/// The slug of the space whose inbox a card goes to. Without it, the
|
/// The slug of the space whose inbox a card goes to, or where a
|
||||||
/// first space that has a board.
|
/// document's title is looked for first. Without it, the first space that
|
||||||
|
/// has a board, and a title in any space.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub space: Option<String>,
|
pub space: Option<String>,
|
||||||
|
/// The document text goes to the end of, as a link to it is written:
|
||||||
|
/// `Plan`, `[[Plan]]` or `/n/work/plan`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub document: Option<String>,
|
||||||
|
/// The scratch note the text comes from, which goes once the text has
|
||||||
|
/// landed: the way a note leaves the scratchpad.
|
||||||
|
#[serde(default)]
|
||||||
|
pub note: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Debug)]
|
#[derive(Serialize, Debug)]
|
||||||
pub struct Captured {
|
pub struct Captured {
|
||||||
pub to: To,
|
pub to: To,
|
||||||
/// The card, or the diary entry.
|
/// The card, the note, or the document — the diary entry among them.
|
||||||
pub id: i64,
|
pub id: i64,
|
||||||
/// Where it went, for the shortcut to say: `work/backlog`, `diary/untitled`.
|
/// Where it went, for the shortcut to say: `work/backlog`, `diary/untitled`,
|
||||||
|
/// `scratch`.
|
||||||
#[serde(rename = "where")]
|
#[serde(rename = "where")]
|
||||||
pub place: String,
|
pub place: String,
|
||||||
}
|
}
|
||||||
@@ -63,7 +79,21 @@ pub async fn capture(
|
|||||||
let captured = match input.to {
|
let captured = match input.to {
|
||||||
To::Card => card(&state.db, text, input.space.as_deref()).await?,
|
To::Card => card(&state.db, text, input.space.as_deref()).await?,
|
||||||
To::Diary => diary(&state.db, state.config.mail.timezone, text, crate::now()).await?,
|
To::Diary => diary(&state.db, state.config.mail.timezone, text, crate::now()).await?,
|
||||||
|
To::Note => note(&state.db, text).await?,
|
||||||
|
To::Document => {
|
||||||
|
let Some(target) = input.document.as_deref() else {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"which document? `document` names it, as a link would".into(),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
document(&state.db, text, target, input.space.as_deref()).await?
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
// Made first, dropped after: a failure in between leaves two copies,
|
||||||
|
// never none.
|
||||||
|
if let Some(note) = input.note.filter(|_| captured.to != To::Note) {
|
||||||
|
scratch::consume(&state.db, note).await?;
|
||||||
|
}
|
||||||
if let Capturer::Token(token) = who {
|
if let Capturer::Token(token) = who {
|
||||||
tracing::info!(token, to = ?captured.to, "captured into {}", captured.place);
|
tracing::info!(token, to = ?captured.to, "captured into {}", captured.place);
|
||||||
}
|
}
|
||||||
@@ -92,6 +122,15 @@ async fn card(db: &SqlitePool, text: &str, space: Option<&str>) -> AppResult<Cap
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn note(db: &SqlitePool, text: &str) -> AppResult<Captured> {
|
||||||
|
let note = scratch::insert(db, text).await?;
|
||||||
|
Ok(Captured {
|
||||||
|
to: To::Note,
|
||||||
|
id: note.id,
|
||||||
|
place: "scratch".into(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// `- 14:05 text`, at the end of today's entry: the one `T` opens. With none
|
/// `- 14:05 text`, at the end of today's entry: the one `T` opens. With none
|
||||||
/// yet, the line starts it the way `T` would, from `templates/day` if there
|
/// yet, the line starts it the way `T` would, from `templates/day` if there
|
||||||
/// is one.
|
/// is one.
|
||||||
@@ -100,33 +139,77 @@ async fn diary(db: &SqlitePool, tz: Tz, text: &str, now: i64) -> AppResult<Captu
|
|||||||
|
|
||||||
let mut tx = crate::db::begin_write(db).await?;
|
let mut tx = crate::db::begin_write(db).await?;
|
||||||
let today = diary::entry_of(&mut tx, tz, crate::notify::local_day(tz, now), now).await?;
|
let today = diary::entry_of(&mut tx, tz, crate::notify::local_day(tz, now), now).await?;
|
||||||
let id = today.id;
|
|
||||||
let body = appended(&today.body, &line);
|
let body = appended(&today.body, &line);
|
||||||
// What an entry just started replaces is its template, not worth keeping.
|
// What an entry just started replaces is its template, not worth keeping.
|
||||||
if !today.created {
|
write(&mut tx, today.id, &body, !today.created, now).await?;
|
||||||
revisions::keep(&mut tx, id, &body, revisions::Reason::Edit).await?;
|
tx.commit().await?;
|
||||||
|
landed(db, To::Diary, today.id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The text as paragraphs of their own at the end of a document, found the
|
||||||
|
/// way a link to it written there would find it.
|
||||||
|
async fn document(
|
||||||
|
db: &SqlitePool,
|
||||||
|
text: &str,
|
||||||
|
target: &str,
|
||||||
|
space: Option<&str>,
|
||||||
|
) -> AppResult<Captured> {
|
||||||
|
let mut tx = crate::db::begin_write(db).await?;
|
||||||
|
let space = match space {
|
||||||
|
Some(slug) => links::find(&mut tx, &format!("/n/{slug}"), None).await?,
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
let id = links::find(&mut tx, target, space)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::BadRequest(format!("no document {target}")))?;
|
||||||
|
let (kind, body): (String, String) =
|
||||||
|
sqlx::query_as("SELECT kind, body FROM nodes WHERE id = ?1")
|
||||||
|
.bind(id)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
if kind != DOCUMENT || trash::in_trash(&mut tx, id).await? {
|
||||||
|
return Err(AppError::BadRequest(format!("{target} is not a document")));
|
||||||
|
}
|
||||||
|
let body = match body.trim_end() {
|
||||||
|
"" => format!("{text}\n"),
|
||||||
|
before => format!("{before}\n\n{text}\n"),
|
||||||
|
};
|
||||||
|
write(&mut tx, id, &body, true, crate::now()).await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
landed(db, To::Document, id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A document's text written, as a save writes it: the revision before it
|
||||||
|
/// kept when it is worth keeping, its media and links read again.
|
||||||
|
async fn write(
|
||||||
|
tx: &mut SqliteConnection,
|
||||||
|
id: i64,
|
||||||
|
body: &str,
|
||||||
|
keep: bool,
|
||||||
|
now: i64,
|
||||||
|
) -> AppResult<()> {
|
||||||
|
if keep {
|
||||||
|
revisions::keep(tx, id, body, revisions::Reason::Edit).await?;
|
||||||
}
|
}
|
||||||
sqlx::query("UPDATE nodes SET body = ?1, updated_at = ?2 WHERE id = ?3")
|
sqlx::query("UPDATE nodes SET body = ?1, updated_at = ?2 WHERE id = ?3")
|
||||||
.bind(&body)
|
.bind(body)
|
||||||
.bind(now)
|
.bind(now)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
media::link_to_node(&mut tx, id, &body).await?;
|
media::link_to_node(tx, id, body).await?;
|
||||||
links::index(&mut tx, id, &body).await?;
|
links::index(tx, id, body).await
|
||||||
tx.commit().await?;
|
}
|
||||||
|
|
||||||
|
/// Where it went into a document, said by its path.
|
||||||
|
async fn landed(db: &SqlitePool, to: To, id: i64) -> AppResult<Captured> {
|
||||||
let place = nodes::path_of(db, id)
|
let place = nodes::path_of(db, id)
|
||||||
.await?
|
.await?
|
||||||
.iter()
|
.iter()
|
||||||
.map(|crumb| crumb.slug.as_str())
|
.map(|crumb| crumb.slug.as_str())
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join("/");
|
.join("/");
|
||||||
Ok(Captured {
|
Ok(Captured { to, id, place })
|
||||||
to: To::Diary,
|
|
||||||
id,
|
|
||||||
place,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `HH:MM` on the wall clock of the configured timezone.
|
/// `HH:MM` on the wall clock of the configured timezone.
|
||||||
@@ -333,6 +416,100 @@ pub(crate) mod tests {
|
|||||||
assert_eq!(found, 1);
|
assert_eq!(found, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A note goes on the scratchpad, and search finds it there.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_note_lands_on_the_scratchpad_and_is_found() {
|
||||||
|
let state = state().await;
|
||||||
|
let app = crate::routes::api_router(1024).with_state(state.clone());
|
||||||
|
let (status, answer) = call(
|
||||||
|
&app,
|
||||||
|
"/capture",
|
||||||
|
TOKEN,
|
||||||
|
r#"{"text":"ask about the lease","to":"note"}"#,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::OK, "{answer}");
|
||||||
|
assert!(answer.contains(r#""where":"scratch""#), "{answer}");
|
||||||
|
|
||||||
|
let found: Vec<String> = sqlx::query_scalar(
|
||||||
|
"SELECT n.body FROM scratch_notes n JOIN scratch_fts ON scratch_fts.rowid = n.id
|
||||||
|
WHERE scratch_fts MATCH '\"lea\"*'",
|
||||||
|
)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(found, ["ask about the lease"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A note leaves the scratchpad by landing somewhere else: at the end of
|
||||||
|
/// a document named as a link names it, or on a card.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_note_leaves_for_a_document_or_a_card() {
|
||||||
|
let state = state().await;
|
||||||
|
let work: i64 =
|
||||||
|
sqlx::query_scalar("SELECT id FROM nodes WHERE slug = 'work' AND parent_id IS NULL")
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let input = serde_json::from_value(serde_json::json!({
|
||||||
|
"parent_id": work, "kind": "document", "name": "Plan", "body": "the plan\n",
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
let session = crate::auth::Session { id: "test".into() };
|
||||||
|
let plan = crate::routes::nodes::create(
|
||||||
|
session,
|
||||||
|
axum::extract::State(state.clone()),
|
||||||
|
axum::Json(input),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.0
|
||||||
|
.id;
|
||||||
|
let note = |body: &'static str| super::scratch::insert(&state.db, body);
|
||||||
|
let first = note("and a step more").await.unwrap().id;
|
||||||
|
let second = note("pay rent\nbefore the 3rd").await.unwrap().id;
|
||||||
|
|
||||||
|
let app = crate::routes::api_router(1024).with_state(state.clone());
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"text": "and a step more", "to": "document", "document": "[[plan]]", "note": first,
|
||||||
|
});
|
||||||
|
let (status, answer) = call(&app, "/capture", TOKEN, &body.to_string()).await;
|
||||||
|
assert_eq!(status, StatusCode::OK, "{answer}");
|
||||||
|
assert!(answer.contains(r#""where":"work/plan""#), "{answer}");
|
||||||
|
let text: String = sqlx::query_scalar("SELECT body FROM nodes WHERE id = ?1")
|
||||||
|
.bind(plan)
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(text, "the plan\n\nand a step more\n");
|
||||||
|
|
||||||
|
let body = serde_json::json!({ "text": "pay rent\nbefore the 3rd", "note": second });
|
||||||
|
assert_eq!(
|
||||||
|
call(&app, "/capture", TOKEN, &body.to_string()).await.0,
|
||||||
|
StatusCode::OK
|
||||||
|
);
|
||||||
|
let left: i64 = sqlx::query_scalar("SELECT count(*) FROM scratch_notes")
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(left, 0);
|
||||||
|
|
||||||
|
// Nothing it could mean, and the note stays where it was.
|
||||||
|
let kept = note("still here").await.unwrap().id;
|
||||||
|
for target in ["Nowhere", "/n/work"] {
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"text": "x", "to": "document", "document": target, "note": kept,
|
||||||
|
});
|
||||||
|
let (status, _) = call(&app, "/capture", TOKEN, &body.to_string()).await;
|
||||||
|
assert_eq!(status, StatusCode::BAD_REQUEST, "{target}");
|
||||||
|
}
|
||||||
|
let left: i64 = sqlx::query_scalar("SELECT count(*) FROM scratch_notes")
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(left, 1);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_line_goes_after_a_list_or_a_blank_line_after_prose() {
|
fn a_line_goes_after_a_list_or_a_blank_line_after_prose() {
|
||||||
assert_eq!(appended("", "- 10:00 x"), "- 10:00 x\n");
|
assert_eq!(appended("", "- 10:00 x"), "- 10:00 x\n");
|
||||||
|
|||||||
@@ -212,6 +212,27 @@ async fn resolve(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What a link written by hand leads to: `/n/work/plan` by its path, and
|
||||||
|
/// `[[Plan]]` or a bare `Plan` by its title, looked for in `space` first —
|
||||||
|
/// the same thing the link would find written in a document there.
|
||||||
|
pub async fn find(
|
||||||
|
db: &mut SqliteConnection,
|
||||||
|
written: &str,
|
||||||
|
space: Option<i64>,
|
||||||
|
) -> AppResult<Option<i64>> {
|
||||||
|
let written = written.trim();
|
||||||
|
match written.strip_prefix("/n/") {
|
||||||
|
Some(path) => resolve_path(db, path).await,
|
||||||
|
None => {
|
||||||
|
let title = written
|
||||||
|
.strip_prefix("[[")
|
||||||
|
.and_then(|rest| rest.strip_suffix("]]"))
|
||||||
|
.unwrap_or(written);
|
||||||
|
resolve_title(db, title.trim(), space).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A node's path from its space, `work/notes/plan`, as `/n/` writes it.
|
/// A node's path from its space, `work/notes/plan`, as `/n/` writes it.
|
||||||
async fn path_text(db: &mut SqliteConnection, id: i64) -> AppResult<String> {
|
async fn path_text(db: &mut SqliteConnection, id: i64) -> AppResult<String> {
|
||||||
let slugs: Vec<String> = sqlx::query_scalar(
|
let slugs: Vec<String> = sqlx::query_scalar(
|
||||||
|
|||||||
+69
-19
@@ -2,11 +2,12 @@
|
|||||||
//!
|
//!
|
||||||
//! A note here has a body and nothing else — no space, no folder, no name —
|
//! A note here has a body and nothing else — no space, no folder, no name —
|
||||||
//! because deciding those is the cost a quick note should not pay. The drawer
|
//! because deciding those is the cost a quick note should not pay. The drawer
|
||||||
//! lists them newest first; one worth keeping is made into a document by the
|
//! lists them newest first, and search finds them. A note leaves by becoming
|
||||||
//! client, which then deletes it here, so a note lives in one place at a time.
|
//! something else — a document, a card, a few lines at the end of a document
|
||||||
|
//! or of today's entry (see `capture`) — so a note lives in one place at a time.
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State},
|
extract::{Path, Query, State},
|
||||||
Json,
|
Json,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -15,7 +16,7 @@ use sqlx::{Row, SqliteConnection};
|
|||||||
use crate::{
|
use crate::{
|
||||||
auth::Session,
|
auth::Session,
|
||||||
error::{AppError, AppResult},
|
error::{AppError, AppResult},
|
||||||
routes::manifest::ScratchMeta,
|
routes::{manifest::ScratchMeta, nodes},
|
||||||
state::AppState,
|
state::AppState,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -55,14 +56,48 @@ fn row_to_note(row: &sqlx::sqlite::SqliteRow) -> Note {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every note, the one touched last first.
|
#[derive(Deserialize)]
|
||||||
pub async fn list(_: Session, State(state): State<AppState>) -> AppResult<Json<Vec<Note>>> {
|
pub struct ListQuery {
|
||||||
let rows = sqlx::query(
|
/// Only the notes this finds, the best match first.
|
||||||
"SELECT id, body, created_at, updated_at FROM scratch_notes
|
#[serde(default)]
|
||||||
ORDER BY updated_at DESC, id DESC",
|
pub q: Option<String>,
|
||||||
)
|
}
|
||||||
.fetch_all(&state.db)
|
|
||||||
.await?;
|
/// As many notes as a search lists; the drawer shows them all.
|
||||||
|
const SEARCH_LIMIT: i64 = 50;
|
||||||
|
|
||||||
|
/// Every note, the one touched last first — or those a search finds.
|
||||||
|
pub async fn list(
|
||||||
|
_: Session,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Query(params): Query<ListQuery>,
|
||||||
|
) -> AppResult<Json<Vec<Note>>> {
|
||||||
|
let rows = match params.q.as_deref() {
|
||||||
|
None => {
|
||||||
|
sqlx::query(
|
||||||
|
"SELECT id, body, created_at, updated_at FROM scratch_notes
|
||||||
|
ORDER BY updated_at DESC, id DESC",
|
||||||
|
)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
Some(q) => {
|
||||||
|
let Some(query) = nodes::fts_query(q) else {
|
||||||
|
return Ok(Json(Vec::new()));
|
||||||
|
};
|
||||||
|
sqlx::query(
|
||||||
|
"SELECT n.id, n.body, n.created_at, n.updated_at FROM scratch_notes n
|
||||||
|
JOIN scratch_fts ON scratch_fts.rowid = n.id
|
||||||
|
WHERE scratch_fts MATCH ?1
|
||||||
|
ORDER BY scratch_fts.rank
|
||||||
|
LIMIT ?2",
|
||||||
|
)
|
||||||
|
.bind(query)
|
||||||
|
.bind(SEARCH_LIMIT)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
};
|
||||||
Ok(Json(rows.iter().map(row_to_note).collect()))
|
Ok(Json(rows.iter().map(row_to_note).collect()))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,18 +106,33 @@ pub async fn create(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Json(input): Json<NoteInput>,
|
Json(input): Json<NoteInput>,
|
||||||
) -> AppResult<Json<Note>> {
|
) -> AppResult<Json<Note>> {
|
||||||
checked(&input.body)?;
|
let note = insert(&state.db, &input.body).await?;
|
||||||
let now = crate::now();
|
state.backup.signal();
|
||||||
|
Ok(Json(note))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A new note at the top of the drawer.
|
||||||
|
pub(crate) async fn insert(db: &sqlx::SqlitePool, body: &str) -> AppResult<Note> {
|
||||||
|
checked(body)?;
|
||||||
let row = sqlx::query(
|
let row = sqlx::query(
|
||||||
"INSERT INTO scratch_notes (body, created_at, updated_at) VALUES (?1, ?2, ?2)
|
"INSERT INTO scratch_notes (body, created_at, updated_at) VALUES (?1, ?2, ?2)
|
||||||
RETURNING id, body, created_at, updated_at",
|
RETURNING id, body, created_at, updated_at",
|
||||||
)
|
)
|
||||||
.bind(&input.body)
|
.bind(body)
|
||||||
.bind(now)
|
.bind(crate::now())
|
||||||
.fetch_one(&state.db)
|
.fetch_one(db)
|
||||||
.await?;
|
.await?;
|
||||||
state.backup.signal();
|
Ok(row_to_note(&row))
|
||||||
Ok(Json(row_to_note(&row)))
|
}
|
||||||
|
|
||||||
|
/// The note gone, now that what it said lives somewhere else. One already
|
||||||
|
/// gone is no error: the text has landed either way.
|
||||||
|
pub(crate) async fn consume(db: &sqlx::SqlitePool, id: i64) -> AppResult<()> {
|
||||||
|
sqlx::query("DELETE FROM scratch_notes WHERE id = ?1")
|
||||||
|
.bind(id)
|
||||||
|
.execute(db)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update(
|
pub async fn update(
|
||||||
|
|||||||
@@ -52,6 +52,25 @@
|
|||||||
let input = $state<HTMLInputElement>()
|
let input = $state<HTMLInputElement>()
|
||||||
let list = $state<HTMLUListElement>()
|
let list = $state<HTMLUListElement>()
|
||||||
const isSearch = $derived(value.startsWith('/'))
|
const isSearch = $derived(value.startsWith('/'))
|
||||||
|
|
||||||
|
/*
|
||||||
|
* A capture line — `c` opens one — writes a thought down somewhere without
|
||||||
|
* going there: a card, a note, a line in today's entry. Tab walks the three
|
||||||
|
* with the text kept. A card stays linked to the document being read when
|
||||||
|
* the line began as one that was.
|
||||||
|
*/
|
||||||
|
const card = untrack(() => (initial.startsWith(':card! ') ? 'card!' : 'card'))
|
||||||
|
const targets = [card, 'scratch', 'today']
|
||||||
|
const targetNames: Record<string, string> = { card: 'card', 'card!': 'card about this', scratch: 'note', today: 'today' }
|
||||||
|
const capture = $derived(/^:(card!?|scratch|today)\s/.exec(value)?.[1])
|
||||||
|
|
||||||
|
function retarget(delta: 1 | -1): boolean {
|
||||||
|
if (!capture) return false
|
||||||
|
const at = targets.indexOf(capture === 'card!' || capture === 'card' ? card : capture)
|
||||||
|
const next = targets[(at + delta + targets.length) % targets.length]
|
||||||
|
value = value.replace(/^:\S+/, `:${next}`)
|
||||||
|
return true
|
||||||
|
}
|
||||||
const historyKey = $derived(isSearch ? 'diary:history:search' : 'diary:history:cmd')
|
const historyKey = $derived(isSearch ? 'diary:history:search' : 'diary:history:cmd')
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -129,7 +148,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 || workspace.foundCard)) await workspace.openSelected()
|
if (query && (workspace.selected || workspace.foundCard || workspace.foundNote)) await workspace.openSelected()
|
||||||
})
|
})
|
||||||
hooks.focusList()
|
hooks.focusList()
|
||||||
return
|
return
|
||||||
@@ -187,7 +206,7 @@
|
|||||||
workspace.move(event.shiftKey ? -1 : 1)
|
workspace.move(event.shiftKey ? -1 : 1)
|
||||||
} else if (event.key === 'Tab') {
|
} else if (event.key === 'Tab') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
complete(event.shiftKey ? -1 : 1)
|
if (!retarget(event.shiftKey ? -1 : 1)) complete(event.shiftKey ? -1 : 1)
|
||||||
} else if (ctrl && isSearch && (event.key === 'n' || event.key === 'p')) {
|
} else if (ctrl && isSearch && (event.key === 'n' || event.key === 'p')) {
|
||||||
// The results, walked without leaving the line; Enter opens the one lit.
|
// The results, walked without leaving the line; Enter opens the one lit.
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@@ -227,6 +246,7 @@
|
|||||||
|
|
||||||
<form onsubmit={submit}>
|
<form onsubmit={submit}>
|
||||||
{#if isSearch}<span class="scope label">{global ? 'all spaces' : (workspace.space?.slug ?? '')}</span>{/if}
|
{#if isSearch}<span class="scope label">{global ? 'all spaces' : (workspace.space?.slug ?? '')}</span>{/if}
|
||||||
|
{#if capture}<span class="scope label" title="Tab: card · note · today">→ {targetNames[capture]} <span class="kbd">tab</span></span>{/if}
|
||||||
<input bind:this={input} bind:value {onkeydown} {oninput} spellcheck="false" autocomplete="off" />
|
<input bind:this={input} bind:value {onkeydown} {oninput} spellcheck="false" autocomplete="off" />
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
import Keys from './Keys.svelte'
|
import Keys from './Keys.svelte'
|
||||||
import { renderMarkdown } from './markdown'
|
import { renderMarkdown } from './markdown'
|
||||||
import { workspace } from './store.svelte'
|
import { workspace } from './store.svelte'
|
||||||
import { relative } from './util'
|
import { headline, relative } from './util'
|
||||||
|
|
||||||
let list = $state<HTMLUListElement>()
|
let list = $state<HTMLUListElement>()
|
||||||
|
|
||||||
@@ -21,11 +21,6 @@
|
|||||||
list?.querySelector<HTMLElement>(`[data-index="${at}"]`)?.scrollIntoView({ block: 'nearest' })
|
list?.querySelector<HTMLElement>(`[data-index="${at}"]`)?.scrollIntoView({ block: 'nearest' })
|
||||||
})
|
})
|
||||||
|
|
||||||
/** What a note is called in the list: its first line that says something. */
|
|
||||||
function headline(body: string): string {
|
|
||||||
return body.split('\n').find((line) => line.trim())?.replace(/^#+\s*/, '').trim() ?? ''
|
|
||||||
}
|
|
||||||
|
|
||||||
function pick(index: number) {
|
function pick(index: number) {
|
||||||
if (workspace.scratchEditing) return
|
if (workspace.scratchEditing) return
|
||||||
workspace.scratchCursor = index
|
workspace.scratchCursor = index
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { carry, into, release } from './carry'
|
import { carry, into, release } from './carry'
|
||||||
import Icon from './Icon.svelte'
|
import Icon from './Icon.svelte'
|
||||||
import { workspace } from './store.svelte'
|
import { workspace } from './store.svelte'
|
||||||
import { formatBytes, formatDay, formatTime, formatWeekday, hitParts, isTouchDevice } from './util'
|
import { formatBytes, formatDay, formatTime, formatWeekday, headline, hitParts, isTouchDevice, relative } from './util'
|
||||||
|
|
||||||
let list = $state<HTMLUListElement>()
|
let list = $state<HTMLUListElement>()
|
||||||
/** Files are being dragged over the list, which takes them into this folder. */
|
/** Files are being dragged over the list, which takes them into this folder. */
|
||||||
@@ -79,7 +79,8 @@
|
|||||||
<span class="name">{workspace.space?.slug ?? 'diary'}</span>
|
<span class="name">{workspace.space?.slug ?? 'diary'}</span>
|
||||||
<span class="count">
|
<span class="count">
|
||||||
{workspace.nodes.length} {workspace.nodes.length === 1 ? 'item' : 'items'}{#if workspace.foundCards.length > 0}
|
{workspace.nodes.length} {workspace.nodes.length === 1 ? 'item' : 'items'}{#if workspace.foundCards.length > 0}
|
||||||
· {workspace.foundCards.length} {workspace.foundCards.length === 1 ? 'card' : 'cards'}{/if}
|
· {workspace.foundCards.length} {workspace.foundCards.length === 1 ? 'card' : 'cards'}{/if}{#if workspace.foundNotes.length > 0}
|
||||||
|
· {workspace.foundNotes.length} {workspace.foundNotes.length === 1 ? 'note' : 'notes'}{/if}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -162,6 +163,20 @@
|
|||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
|
<!-- Then the notes it found on the scratchpad: Enter lowers it, at the note. -->
|
||||||
|
{#if workspace.foundNotes.length > 0}
|
||||||
|
<li class="daybreak label rule"><span>notes</span></li>
|
||||||
|
{/if}
|
||||||
|
{#each workspace.foundNotes as note, at (note.id)}
|
||||||
|
{@const index = workspace.nodes.length + workspace.foundCards.length + at}
|
||||||
|
<li class="item">
|
||||||
|
<button class="row note" class:active={index === workspace.cursor} data-index={index} onclick={() => pick(index)}>
|
||||||
|
<span class="dot"></span>
|
||||||
|
<span class="title">{headline(note.body) || '—'}<span class="where">scratch</span></span>
|
||||||
|
<span class="aside">{relative(note.updated_at)}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
{#if workspace.rows === 0}
|
{#if workspace.rows === 0}
|
||||||
<li class="empty muted">
|
<li class="empty muted">
|
||||||
{#if workspace.query}
|
{#if workspace.query}
|
||||||
@@ -266,6 +281,7 @@
|
|||||||
.row.open.active .dot { background: var(--ink); }
|
.row.open.active .dot { background: var(--ink); }
|
||||||
/* A card is told from a node by its square. */
|
/* A card is told from a node by its square. */
|
||||||
.row.card .dot { border: 1px solid currentColor; border-radius: 1px; }
|
.row.card .dot { border: 1px solid currentColor; border-radius: 1px; }
|
||||||
|
.row.note .dot { border: 1px dashed currentColor; background: none; }
|
||||||
.row.card.done .title { color: var(--muted); text-decoration: line-through; }
|
.row.card.done .title { color: var(--muted); text-decoration: line-through; }
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
|
|||||||
@@ -292,6 +292,11 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
|
|||||||
new: () => workspace.newScratchNote(),
|
new: () => workspace.newScratchNote(),
|
||||||
write: () => workspace.editScratchNote(),
|
write: () => workspace.editScratchNote(),
|
||||||
keep: () => guard(() => workspace.keepScratchNote()),
|
keep: () => guard(() => workspace.keepScratchNote()),
|
||||||
|
card: () => guard(() => workspace.cardScratchNote()),
|
||||||
|
append: () => {
|
||||||
|
const open = [workspace.tile, ...workspace.tiles].find((tile) => tile.open?.kind === 'document')?.open
|
||||||
|
line(`:append ${open ? open.name.trim() || open.slug : ''}`)
|
||||||
|
},
|
||||||
drop: () => {
|
drop: () => {
|
||||||
if (!workspace.scratchNote) return
|
if (!workspace.scratchNote) return
|
||||||
void (async () => {
|
void (async () => {
|
||||||
|
|||||||
+10
-3
@@ -105,7 +105,7 @@ export interface ApiToken {
|
|||||||
|
|
||||||
/** Where `POST /api/capture` put the text. */
|
/** Where `POST /api/capture` put the text. */
|
||||||
export interface Captured {
|
export interface Captured {
|
||||||
to: 'card' | 'diary'
|
to: 'card' | 'diary' | 'note' | 'document'
|
||||||
id: number
|
id: number
|
||||||
where: string
|
where: string
|
||||||
}
|
}
|
||||||
@@ -608,8 +608,15 @@ export const api = {
|
|||||||
},
|
},
|
||||||
) => request<Moved>(`/cards/${id}`, { method: 'PUT', body: body(patch) }),
|
) => request<Moved>(`/cards/${id}`, { method: 'PUT', body: body(patch) }),
|
||||||
|
|
||||||
/** The scratchpad's notes, the one touched last first. */
|
/** The scratchpad's notes, the one touched last first — or those a search finds, the best first. */
|
||||||
scratch: () => request<ScratchNote[]>('/scratch'),
|
scratch: (q?: string) => request<ScratchNote[]>(`/scratch${q === undefined ? '' : `?q=${encodeURIComponent(q)}`}`),
|
||||||
|
/**
|
||||||
|
* Text written down somewhere: a card in an inbox, a line in today's entry,
|
||||||
|
* a note, the end of a document. With `note`, the text is that note's, and
|
||||||
|
* it leaves the scratchpad once the text has landed.
|
||||||
|
*/
|
||||||
|
capture: (input: { text: string; to: Captured['to']; space?: string; document?: string; note?: number }) =>
|
||||||
|
request<Captured>('/capture', { method: 'POST', body: body(input) }),
|
||||||
createScratch: (text: string) => request<ScratchNote>('/scratch', { method: 'POST', body: body({ body: text }) }),
|
createScratch: (text: string) => request<ScratchNote>('/scratch', { method: 'POST', body: body({ body: text }) }),
|
||||||
updateScratch: (id: number, text: string) =>
|
updateScratch: (id: number, text: string) =>
|
||||||
request<ScratchNote>(`/scratch/${id}`, { method: 'PUT', body: body({ body: text }) }),
|
request<ScratchNote>(`/scratch/${id}`, { method: 'PUT', body: body({ body: text }) }),
|
||||||
|
|||||||
+45
-2
@@ -189,6 +189,34 @@ const spaceNames = () => workspace.spaces.map((space) => space.slug)
|
|||||||
/** The capture tokens' names, for Tab after `:token rm`; null until fetched. */
|
/** The capture tokens' names, for Tab after `:token rm`; null until fetched. */
|
||||||
let tokenNames: string[] | null = null
|
let tokenNames: string[] | null = null
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The titles of the documents a search finds for what is typed after
|
||||||
|
* `:append`, for Tab — fetched as the typing goes, so by the time Tab is
|
||||||
|
* pressed they are usually there.
|
||||||
|
*/
|
||||||
|
let titlesFor = ''
|
||||||
|
let titles: string[] = []
|
||||||
|
function documentTitles(arg: string): string[] {
|
||||||
|
const wanted = arg.trim()
|
||||||
|
if (wanted && wanted !== titlesFor) {
|
||||||
|
titlesFor = wanted
|
||||||
|
void api.listNodes({ q: wanted }).then(
|
||||||
|
(found) => {
|
||||||
|
if (titlesFor !== wanted) return
|
||||||
|
titles = found.filter((node) => node.kind === 'document').map((node) => node.name.trim() || node.slug)
|
||||||
|
},
|
||||||
|
() => {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return titles
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The document in a tile, the focused one first: what a note goes to the end of unless told otherwise. */
|
||||||
|
function documentOnScreen(): string | undefined {
|
||||||
|
const tile = [workspace.tile, ...workspace.tiles].find((t) => t.open?.kind === 'document')
|
||||||
|
return tile?.open ? `/n/${tile.open.path.map((crumb) => crumb.slug).join('/')}` : undefined
|
||||||
|
}
|
||||||
|
|
||||||
/** The templates of the space last asked about, for `:new` to complete. */
|
/** The templates of the space last asked about, for `:new` to complete. */
|
||||||
let templateCache: { space: number; names: string[] } | null = null
|
let templateCache: { space: number; names: string[] } | null = null
|
||||||
|
|
||||||
@@ -547,6 +575,19 @@ export const commands: CommandSpec[] = [
|
|||||||
await workspace.guard(() => workspace.scratch(arg.trim()))
|
await workspace.guard(() => workspace.scratch(arg.trim()))
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'append',
|
||||||
|
args: '[document]',
|
||||||
|
group: 'documents',
|
||||||
|
help: 'put the scratch note at the end of a document, by its title — Tab completes it — or the one on screen, and take it off the scratchpad',
|
||||||
|
complete: documentTitles,
|
||||||
|
run: async (arg) => {
|
||||||
|
if (!workspace.scratchOpen) return workspace.say('no scratch note — Q lowers the scratchpad', 'error')
|
||||||
|
const target = arg.trim() || documentOnScreen()
|
||||||
|
if (!target) return workspace.say('usage: :append <document> — or open the one it goes to', 'error')
|
||||||
|
await workspace.guard(() => workspace.appendScratchNote(target))
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'keep',
|
name: 'keep',
|
||||||
args: '[title]',
|
args: '[title]',
|
||||||
@@ -697,9 +738,11 @@ export const commands: CommandSpec[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'today',
|
name: 'today',
|
||||||
|
args: '[text]',
|
||||||
group: 'documents',
|
group: 'documents',
|
||||||
help: "open today's diary entry, starting one if there is none yet",
|
help: "open today's diary entry, starting one if there is none yet; with text, a timed line at its end without going there",
|
||||||
run: async () => {
|
run: async (arg) => {
|
||||||
|
if (arg.trim()) return workspace.guard(() => workspace.logToday(arg.trim()))
|
||||||
await workspace.guard(() => workspace.openDay())
|
await workspace.guard(() => workspace.openDay())
|
||||||
hooks.focusEditor()
|
hooks.focusEditor()
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -113,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 — reading a document, a card about it" },
|
{ 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; Tab on the line makes it a note, or a line in today's entry" },
|
||||||
{ 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' },
|
||||||
@@ -190,6 +190,8 @@ export const bindings = [
|
|||||||
{ context: 'drawer', keys: ['o', 'O', 'c'], action: 'new', label: 'o', caps: ['o'], hint: 'new', help: 'a new note, written straight away — it saves itself' },
|
{ context: 'drawer', keys: ['o', 'O', 'c'], action: 'new', label: 'o', caps: ['o'], hint: 'new', help: 'a new note, written straight away — it saves itself' },
|
||||||
{ context: 'drawer', keys: ['Enter', 'e', 'i', 'a'], action: 'write', label: 'Enter · e · i', caps: ['↵'], hint: 'write', help: 'write the note — Esc in normal mode, or :q, comes back to the notes' },
|
{ context: 'drawer', keys: ['Enter', 'e', 'i', 'a'], action: 'write', label: 'Enter · e · i', caps: ['↵'], hint: 'write', help: 'write the note — Esc in normal mode, or :q, comes back to the notes' },
|
||||||
{ context: 'drawer', keys: ['K'], action: 'keep', hint: 'keep as document', help: 'keep the note: a document in the folder being browsed, titled by its first line — :keep <title> names it' },
|
{ context: 'drawer', keys: ['K'], action: 'keep', hint: 'keep as document', help: 'keep the note: a document in the folder being browsed, titled by its first line — :keep <title> names it' },
|
||||||
|
{ context: 'drawer', keys: ['C'], action: 'card', hint: 'card', help: 'make the note a card where :card puts one — the first line its title, the rest its description' },
|
||||||
|
{ context: 'drawer', keys: ['A'], action: 'append', hint: 'append', help: 'put the note at the end of a document — :append, with the one on screen filled in; Tab completes a title' },
|
||||||
{ context: 'drawer', keys: ['dd'], action: 'drop', hint: 'drop', help: 'drop the note (asks y/N)' },
|
{ context: 'drawer', keys: ['dd'], action: 'drop', hint: 'drop', help: 'drop the note (asks y/N)' },
|
||||||
{ context: 'drawer', keys: [':'], action: 'line' },
|
{ context: 'drawer', keys: [':'], action: 'line' },
|
||||||
{ context: 'drawer', keys: ['?'], action: 'help' },
|
{ context: 'drawer', keys: ['?'], action: 'help' },
|
||||||
|
|||||||
+109
-19
@@ -139,8 +139,9 @@ 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. */
|
/** The cards a search found, listed after the nodes, and the notes after them. */
|
||||||
foundCards = $state<AgendaCard[]>([])
|
foundCards = $state<AgendaCard[]>([])
|
||||||
|
foundNotes = $state<ScratchNote[]>([])
|
||||||
cursor = $state(0)
|
cursor = $state(0)
|
||||||
query = $state('')
|
query = $state('')
|
||||||
|
|
||||||
@@ -446,9 +447,9 @@ 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. */
|
/** How many rows the list has to walk: the nodes, then the cards and the notes a search found. */
|
||||||
get rows(): number {
|
get rows(): number {
|
||||||
return this.nodes.length + this.foundCards.length
|
return this.nodes.length + this.foundCards.length + this.foundNotes.length
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The card a search found under the cursor, past the nodes it found. */
|
/** The card a search found under the cursor, past the nodes it found. */
|
||||||
@@ -456,6 +457,12 @@ class Workspace {
|
|||||||
return this.cursor >= this.nodes.length ? this.foundCards[this.cursor - this.nodes.length] : undefined
|
return this.cursor >= this.nodes.length ? this.foundCards[this.cursor - this.nodes.length] : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The note a search found under the cursor, past the cards. */
|
||||||
|
get foundNote(): ScratchNote | undefined {
|
||||||
|
const at = this.cursor - this.nodes.length - this.foundCards.length
|
||||||
|
return at >= 0 ? this.foundNotes[at] : 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
|
||||||
@@ -1003,10 +1010,16 @@ class Workspace {
|
|||||||
const keepId = keep ?? this.selected?.id
|
const keepId = keep ?? this.selected?.id
|
||||||
if (this.query) {
|
if (this.query) {
|
||||||
const space = this.#searchSpace
|
const space = this.#searchSpace
|
||||||
;[this.nodes, this.foundCards] = await Promise.all([api.listNodes({ q: this.query, space }), api.searchCards(this.query, space)])
|
// A note is in no space, so every search looks through them all.
|
||||||
|
;[this.nodes, this.foundCards, this.foundNotes] = await Promise.all([
|
||||||
|
api.listNodes({ q: this.query, space }),
|
||||||
|
api.searchCards(this.query, space),
|
||||||
|
api.scratch(this.query),
|
||||||
|
])
|
||||||
} else {
|
} else {
|
||||||
this.nodes = await api.listNodes({ parent: this.parentId ?? undefined })
|
this.nodes = await api.listNodes({ parent: this.parentId ?? undefined })
|
||||||
this.foundCards = []
|
this.foundCards = []
|
||||||
|
this.foundNotes = []
|
||||||
}
|
}
|
||||||
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.rows - 1, 0))
|
this.cursor = index >= 0 ? index : Math.min(this.cursor, Math.max(this.rows - 1, 0))
|
||||||
@@ -1094,6 +1107,8 @@ class Workspace {
|
|||||||
async openSelected(edit = false) {
|
async openSelected(edit = false) {
|
||||||
const found = this.foundCard
|
const found = this.foundCard
|
||||||
if (found) return this.turnTo(found)
|
if (found) return this.turnTo(found)
|
||||||
|
const note = this.foundNote
|
||||||
|
if (note) return this.showNote(note.id)
|
||||||
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
|
||||||
@@ -1831,11 +1846,11 @@ class Workspace {
|
|||||||
* a thought down must not cost the place you were at. On the board itself
|
* a thought down must not cost the place you were at. On the board itself
|
||||||
* it goes on the list under the cursor instead.
|
* it goes on the list under the cursor instead.
|
||||||
*/
|
*/
|
||||||
async createCard(title: string, nodeId: number | null = null) {
|
async createCard(title: string, nodeId: number | null = null, body = '') {
|
||||||
if (this.focus === 'board' || this.focus === 'card') {
|
if (this.focus === 'board' || this.focus === 'card') {
|
||||||
const list = this.list
|
const list = this.list
|
||||||
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, body })
|
||||||
await this.reloadBoard(this.boardSpaceId, card.id)
|
await this.reloadBoard(this.boardSpaceId, card.id)
|
||||||
await this.#reloadCardsAbout()
|
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}`)
|
||||||
@@ -1845,7 +1860,7 @@ class Workspace {
|
|||||||
if (!space) return this.say('no space has a board — :set board turns one on', 'error')
|
if (!space) return this.say('no space has a board — :set board turns one on', 'error')
|
||||||
const inbox = (await api.board(space.id)).lists[0]
|
const inbox = (await api.board(space.id)).lists[0]
|
||||||
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, body })
|
||||||
await this.loadAgenda()
|
await this.loadAgenda()
|
||||||
await this.#reloadCardsAbout()
|
await this.#reloadCardsAbout()
|
||||||
this.say(`carded "${card.title}" → ${space.slug}/${inbox.name}`)
|
this.say(`carded "${card.title}" → ${space.slug}/${inbox.name}`)
|
||||||
@@ -1993,11 +2008,91 @@ class Workspace {
|
|||||||
async dropScratchNote(say = true, note = this.scratchNote) {
|
async dropScratchNote(say = true, note = this.scratchNote) {
|
||||||
if (!note) return
|
if (!note) return
|
||||||
await api.deleteScratch(note.id)
|
await api.deleteScratch(note.id)
|
||||||
this.scratchNotes = this.scratchNotes.filter((n) => n.id !== note.id)
|
this.#forgetNote(note.id)
|
||||||
this.scratchCursor = Math.min(this.scratchCursor, Math.max(this.scratchNotes.length - 1, 0))
|
|
||||||
if (say) this.say('scratch note dropped')
|
if (say) this.say('scratch note dropped')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A note gone from the drawer and from the search results, the cursors kept in range. */
|
||||||
|
#forgetNote(id: number) {
|
||||||
|
this.scratchNotes = this.scratchNotes.filter((n) => n.id !== id)
|
||||||
|
this.scratchCursor = Math.min(this.scratchCursor, Math.max(this.scratchNotes.length - 1, 0))
|
||||||
|
if (!this.foundNotes.some((n) => n.id === id)) return
|
||||||
|
this.foundNotes = this.foundNotes.filter((n) => n.id !== id)
|
||||||
|
this.cursor = Math.min(this.cursor, Math.max(this.rows - 1, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A note found by a search: the drawer down, with it under the cursor. */
|
||||||
|
async showNote(id: number) {
|
||||||
|
if (!this.scratchOpen) await this.toggleScratch()
|
||||||
|
else this.scratchNotes = await api.scratch()
|
||||||
|
const at = this.scratchNotes.findIndex((note) => note.id === id)
|
||||||
|
if (at < 0) return this.say('that note is gone', 'error')
|
||||||
|
this.scratchCursor = at
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The note under the cursor, saved and closed if it was being written, for it to leave. */
|
||||||
|
async #leavingNote(): Promise<ScratchNote | undefined> {
|
||||||
|
if (this.scratchEditing && !(await this.leaveWriting())) return
|
||||||
|
const note = this.scratchNote
|
||||||
|
if (!note) this.say('no scratch note under the cursor', 'error')
|
||||||
|
return note
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A note split as a card or a document takes it: the first line, a `# heading` or not, and the rest. */
|
||||||
|
#split(note: ScratchNote): { title: string; rest: string } {
|
||||||
|
const [first, ...rest] = note.body.replace(/^\s+/, '').split('\n')
|
||||||
|
return { title: first.replace(/^#+\s*/, '').trim().slice(0, 200), rest: rest.join('\n').replace(/^\n+/, '') }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `C`: the note under the cursor made a card, where `:card` would put one
|
||||||
|
* — the list under the board's cursor, or this space's inbox. The first
|
||||||
|
* line is its title, and the rest its description.
|
||||||
|
*/
|
||||||
|
async cardScratchNote() {
|
||||||
|
const note = await this.#leavingNote()
|
||||||
|
if (!note) return
|
||||||
|
const { title, rest } = this.#split(note)
|
||||||
|
if (!title) return this.say('the note has nothing to call a card by', 'error')
|
||||||
|
// Made first, dropped after: a failure in between leaves two copies, never none.
|
||||||
|
await this.createCard(title, null, rest)
|
||||||
|
await this.dropScratchNote(false, note)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `:append <document>`: the note under the cursor put at the end of a
|
||||||
|
* document, named as a link names it, and taken off the scratchpad. Open
|
||||||
|
* drafts are written first, and a tile showing the document reads it again.
|
||||||
|
*/
|
||||||
|
async appendScratchNote(document: string) {
|
||||||
|
const note = await this.#leavingNote()
|
||||||
|
if (!note) return
|
||||||
|
await this.flush()
|
||||||
|
const space = this.space?.role === 'diary' ? undefined : this.space?.slug
|
||||||
|
const landed = await api.capture({ text: note.body, to: 'document', document, space, note: note.id })
|
||||||
|
this.#forgetNote(note.id)
|
||||||
|
await this.#reread(landed.id)
|
||||||
|
this.say(`appended to ${landed.where}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `:today <text>`: a line at the end of today's diary entry, without going there. */
|
||||||
|
async logToday(text: string) {
|
||||||
|
await this.flush()
|
||||||
|
const landed = await api.capture({ text, to: 'diary' })
|
||||||
|
await this.#reread(landed.id)
|
||||||
|
this.say(`logged in ${landed.where}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every tile showing `id` reads it again, after it was written somewhere else. */
|
||||||
|
async #reread(id: number) {
|
||||||
|
for (const tile of this.tiles) {
|
||||||
|
if (tile.open?.id !== id || tile.dirty) continue
|
||||||
|
const node = await api.getNode(id)
|
||||||
|
tile.open = node
|
||||||
|
tile.draft = { name: node.name, body: node.body, created_at: node.created_at }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** `:scratch <text>`: a note from the command line, without the drawer. */
|
/** `:scratch <text>`: a note from the command line, without the drawer. */
|
||||||
async scratch(text: string) {
|
async scratch(text: string) {
|
||||||
const note = await api.createScratch(text)
|
const note = await api.createScratch(text)
|
||||||
@@ -2011,19 +2106,14 @@ class Workspace {
|
|||||||
* line is the title — a `# heading` or not — and the rest the body.
|
* line is the title — a `# heading` or not — and the rest the body.
|
||||||
*/
|
*/
|
||||||
async keepScratchNote(title = '') {
|
async keepScratchNote(title = '') {
|
||||||
if (this.scratchEditing && !(await this.leaveWriting())) return
|
const note = await this.#leavingNote()
|
||||||
const note = this.scratchNote
|
if (!note) return
|
||||||
if (!note) return this.say('no scratch note to keep', 'error')
|
|
||||||
const parent = this.parentId ?? this.spaceId ?? this.spaces.find((space) => space.role !== 'diary')?.id
|
const parent = this.parentId ?? this.spaceId ?? this.spaces.find((space) => space.role !== 'diary')?.id
|
||||||
if (parent === undefined || parent === null) return this.say('no space to keep it in', 'error')
|
if (parent === undefined || parent === null) return this.say('no space to keep it in', 'error')
|
||||||
|
|
||||||
let name = title.trim()
|
const split = this.#split(note)
|
||||||
let body = note.body
|
const name = title.trim() || split.title
|
||||||
if (!name) {
|
const body = title.trim() ? note.body : split.rest
|
||||||
const [first, ...rest] = note.body.replace(/^\s+/, '').split('\n')
|
|
||||||
name = first.replace(/^#+\s*/, '').trim().slice(0, 200)
|
|
||||||
body = rest.join('\n').replace(/^\n+/, '')
|
|
||||||
}
|
|
||||||
const node = await api.createNode({ parent_id: parent, name, body })
|
const node = await api.createNode({ parent_id: parent, name, body })
|
||||||
// Made first, dropped after: a failure in between leaves two copies, never none.
|
// Made first, dropped after: a failure in between leaves two copies, never none.
|
||||||
await this.dropScratchNote(false, note)
|
await this.dropScratchNote(false, note)
|
||||||
|
|||||||
@@ -387,3 +387,8 @@ export function urgency(card: { due_at: number | null; done_at: number | null })
|
|||||||
if (card.due_at < start) return 'overdue'
|
if (card.due_at < start) return 'overdue'
|
||||||
return card.due_at < start + 86400 ? 'today' : ''
|
return card.due_at < start + 86400 ? 'today' : ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What a note is called in a list: its first line that says something. */
|
||||||
|
export function headline(body: string): string {
|
||||||
|
return body.split('\n').find((line) => line.trim())?.replace(/^#+\s*/, '').trim() ?? ''
|
||||||
|
}
|
||||||
Reference in new issue
Block a user