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:
nvrl committed 2026-10-22 11:22:55 +02:00
1 parent ccc91028e7
commit 2233fa41a6
20 files changed
+561 -84

No files matched your search

+8 -5
View File
@@ -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
View File
@@ -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 |
+8 -2
View File
@@ -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"}`.
+5 -2
View File
@@ -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 |
+2
View File
@@ -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.
![A search that found a line in a log file](../images/search.png) ![A search that found a line in a log file](../images/search.png)
+4 -1
View File
@@ -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.
+11
View File
@@ -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.
+18
View File
@@ -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
View File
@@ -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");
+21
View File
@@ -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
View File
@@ -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(
+22 -2
View File
@@ -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>
+1 -6
View File
@@ -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
+18 -2
View File
@@ -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 {
+5
View File
@@ -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
View File
@@ -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
View File
@@ -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()
}, },
+3 -1
View File
@@ -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
View File
@@ -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)
+5
View File
@@ -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() ?? ''
}