From 2233fa41a6c3118f2d1eded4617e85b698e1c8ac Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Thu, 22 Oct 2026 11:22:55 +0200 Subject: [PATCH] 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 ` now writes. --- README.md | 13 +- docs/wiki/API.md | 4 +- docs/wiki/Capture.md | 10 +- docs/wiki/Commands.md | 7 +- docs/wiki/Files-and-Search.md | 2 + docs/wiki/Guide.md | 5 +- docs/wiki/Writing.md | 11 ++ migrations/0028_scratch_fts.sql | 18 +++ src/routes/capture.rs | 213 +++++++++++++++++++++++++++++--- src/routes/links.rs | 21 ++++ src/routes/scratch.rs | 88 ++++++++++--- web/src/lib/CommandLine.svelte | 24 +++- web/src/lib/Scratch.svelte | 7 +- web/src/lib/Sidebar.svelte | 20 ++- web/src/lib/actions.ts | 5 + web/src/lib/api.ts | 13 +- web/src/lib/commands.ts | 47 ++++++- web/src/lib/keymap.ts | 4 +- web/src/lib/store.svelte.ts | 128 ++++++++++++++++--- web/src/lib/util.ts | 5 + 20 files changed, 561 insertions(+), 84 deletions(-) create mode 100644 migrations/0028_scratch_fts.sql diff --git a/README.md b/README.md index 3d29501..be631ae 100644 --- a/README.md +++ b/README.md @@ -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 | | `C` | the diary by month (`:cal`) — see [The diary](#the-diary) | | `[d` / `]d` | the diary entry before / after the one open, by date | -| `c` | capture a card into this space's inbox without leaving where you are — 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) | | `Esc`, `q` | close the document, and its tile when there are several | | `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 ` outside the board puts the card in 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 | | --- | --- | @@ -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 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 -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 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 }` | | `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 | -| `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 | | `DELETE` | `/api/tokens/{id}` | remove a capture token | | `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` | | `POST` | `/api/cards/{id}/move` | to another list and place in it; `again` as above | | `GET` | `/api/cards?q=…` | cards whose title, summary or a checklist step matches, open ones first; `space` keeps it to one board | -| `GET`/`POST` | `/api/scratch` | the scratchpad's notes, the one touched last first / a new note `{ body }` | +| `GET`/`POST` | `/api/scratch` | the scratchpad's notes, the one touched last first, or with `?q=` those a search finds / a new note `{ body }` | | `PUT`/`DELETE` | `/api/scratch/{id}` | rewrite a note `{ body }` / drop it | | `POST` | `/api/nodes/{id}/move` | reparent, and place among the new siblings | | `POST` | `/api/nodes/move` | `{ ids, parent_id }`: several into one folder, all or none | diff --git a/docs/wiki/API.md b/docs/wiki/API.md index 9b33a74..b38dfbc 100644 --- a/docs/wiki/API.md +++ b/docs/wiki/API.md @@ -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 }` | | `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 | -| `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 | | `DELETE` | `/api/tokens/{id}` | remove a capture token | | `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` | | `POST` | `/api/cards/{id}/move` | to another list and place in it; `again` as above | | `GET` | `/api/cards?q=…` | cards whose title, summary or a checklist step matches, open ones first; `space` keeps it to one board | -| `GET`/`POST` | `/api/scratch` | the scratchpad's notes, the one touched last first / a new note `{ body }` | +| `GET`/`POST` | `/api/scratch` | the scratchpad's notes, the one touched last first, or with `?q=` those a search finds / a new note `{ body }` | | `PUT`/`DELETE` | `/api/scratch/{id}` | rewrite a note `{ body }` / drop it | | `POST` | `/api/nodes/{id}/move` | reparent, and place among the new siblings | | `POST` | `/api/nodes/move` | `{ ids, parent_id }`: several into one folder, all or none | diff --git a/docs/wiki/Capture.md b/docs/wiki/Capture.md index 87995ee..532cf0c 100644 --- a/docs/wiki/Capture.md +++ b/docs/wiki/Capture.md @@ -27,14 +27,20 @@ curl -H "Authorization: Bearer nwc_…" -H 'content-type: application/json' \ | field | meaning | | --- | --- | | `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"` | -| `space` | for a card, the slug of the space whose inbox it goes in | +| `to` | `"card"` (the default), `"diary"`, `"note"` or `"document"` | +| `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 of the first space that has a board. - `"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 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"}`. diff --git a/docs/wiki/Commands.md b/docs/wiki/Commands.md index a6c3217..9ffe5c4 100644 --- a/docs/wiki/Commands.md +++ b/docs/wiki/Commands.md @@ -50,7 +50,7 @@ arguments and `↑` / `↓` recall earlier lines. | `[d / ]d` | the diary entry before / after the one open, by date | | `Ctrl-P` | find anything, in every space — Enter opens the result lit beside them, n goes on | | `b` | the board, in a tile: this space's, or the last one shown — 1 … 9 there shows another's | -| `c` | capture a card into this space's inbox, without leaving here — 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 | | `/` | 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 | @@ -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 | | `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 | +| `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) | | `q · Esc · Q` | raise the scratchpad | @@ -173,10 +175,11 @@ do: `:w!`, `:space! <name>`, `:import!`. | 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 | +| `: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 | | `: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 | -| `: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 | | `:prev` | open the previous node | | `:first` | open the first node in the list | diff --git a/docs/wiki/Files-and-Search.md b/docs/wiki/Files-and-Search.md index d797271..73b9f38 100644 --- a/docs/wiki/Files-and-Search.md +++ b/docs/wiki/Files-and-Search.md @@ -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 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. +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) diff --git a/docs/wiki/Guide.md b/docs/wiki/Guide.md index b2baf17..86d1010 100644 --- a/docs/wiki/Guide.md +++ b/docs/wiki/Guide.md @@ -84,7 +84,10 @@ More in [Writing](Writing.md). 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 -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 shows the board of another space. diff --git a/docs/wiki/Writing.md b/docs/wiki/Writing.md index 13cde3a..70340ae 100644 --- a/docs/wiki/Writing.md +++ b/docs/wiki/Writing.md @@ -67,6 +67,8 @@ a body and the time it was last touched, newest on top. | `o` | a new note, written straight away | | `Enter`, `e`, `i` | write the note under the cursor | | `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) | | `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 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 them in `.workspace.json`, so `:import! /` puts them back. diff --git a/migrations/0028_scratch_fts.sql b/migrations/0028_scratch_fts.sql new file mode 100644 index 0000000..2d2a2a2 --- /dev/null +++ b/migrations/0028_scratch_fts.sql @@ -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; diff --git a/src/routes/capture.rs b/src/routes/capture.rs index 1c58ebc..9a1b95a 100644 --- a/src/routes/capture.rs +++ b/src/routes/capture.rs @@ -7,14 +7,16 @@ use axum::{extract::State, Json}; use chrono::{TimeZone, Utc}; use chrono_tz::Tz; use serde::{Deserialize, Serialize}; -use sqlx::SqlitePool; +use sqlx::{SqliteConnection, SqlitePool}; use crate::{ auth::Capturer, error::{AppError, AppResult}, routes::{ boards::{self, CardInput}, - diary, links, media, nodes, revisions, + diary, links, media, + nodes::{self, DOCUMENT}, + revisions, scratch, trash, }, state::AppState, }; @@ -27,6 +29,10 @@ pub enum To { Card, /// A line in today's diary entry: something that happened. 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)] @@ -34,18 +40,28 @@ pub struct CaptureInput { pub text: String, #[serde(default)] pub to: To, - /// The slug of the space whose inbox a card goes to. Without it, the - /// first space that has a board. + /// The slug of the space whose inbox a card goes to, or where a + /// document's title is looked for first. Without it, the first space that + /// has a board, and a title in any space. #[serde(default)] 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)] pub struct Captured { pub to: To, - /// The card, or the diary entry. + /// The card, the note, or the document — the diary entry among them. 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")] pub place: String, } @@ -63,7 +79,21 @@ pub async fn capture( let captured = match input.to { 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::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 { 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 /// yet, the line starts it the way `T` would, from `templates/day` if there /// 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 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); // What an entry just started replaces is its template, not worth keeping. - if !today.created { - revisions::keep(&mut tx, id, &body, revisions::Reason::Edit).await?; + write(&mut tx, today.id, &body, !today.created, now).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") - .bind(&body) + .bind(body) .bind(now) .bind(id) .execute(&mut *tx) .await?; - media::link_to_node(&mut tx, id, &body).await?; - links::index(&mut tx, id, &body).await?; - tx.commit().await?; + media::link_to_node(tx, id, body).await?; + links::index(tx, id, body).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) .await? .iter() .map(|crumb| crumb.slug.as_str()) .collect::<Vec<_>>() .join("/"); - Ok(Captured { - to: To::Diary, - id, - place, - }) + Ok(Captured { to, id, place }) } /// `HH:MM` on the wall clock of the configured timezone. @@ -333,6 +416,100 @@ pub(crate) mod tests { 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] fn a_line_goes_after_a_list_or_a_blank_line_after_prose() { assert_eq!(appended("", "- 10:00 x"), "- 10:00 x\n"); diff --git a/src/routes/links.rs b/src/routes/links.rs index 2652c52..e47e3f7 100644 --- a/src/routes/links.rs +++ b/src/routes/links.rs @@ -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. async fn path_text(db: &mut SqliteConnection, id: i64) -> AppResult<String> { let slugs: Vec<String> = sqlx::query_scalar( diff --git a/src/routes/scratch.rs b/src/routes/scratch.rs index a1ed22e..9ffa55d 100644 --- a/src/routes/scratch.rs +++ b/src/routes/scratch.rs @@ -2,11 +2,12 @@ //! //! 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 -//! lists them newest first; one worth keeping is made into a document by the -//! client, which then deletes it here, so a note lives in one place at a time. +//! lists them newest first, and search finds them. A note leaves by becoming +//! 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::{ - extract::{Path, State}, + extract::{Path, Query, State}, Json, }; use serde::{Deserialize, Serialize}; @@ -15,7 +16,7 @@ use sqlx::{Row, SqliteConnection}; use crate::{ auth::Session, error::{AppError, AppResult}, - routes::manifest::ScratchMeta, + routes::{manifest::ScratchMeta, nodes}, state::AppState, }; @@ -55,14 +56,48 @@ fn row_to_note(row: &sqlx::sqlite::SqliteRow) -> Note { } } -/// Every note, the one touched last first. -pub async fn list(_: Session, State(state): State<AppState>) -> AppResult<Json<Vec<Note>>> { - let rows = sqlx::query( - "SELECT id, body, created_at, updated_at FROM scratch_notes - ORDER BY updated_at DESC, id DESC", - ) - .fetch_all(&state.db) - .await?; +#[derive(Deserialize)] +pub struct ListQuery { + /// Only the notes this finds, the best match first. + #[serde(default)] + pub q: Option<String>, +} + +/// 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())) } @@ -71,18 +106,33 @@ pub async fn create( State(state): State<AppState>, Json(input): Json<NoteInput>, ) -> AppResult<Json<Note>> { - checked(&input.body)?; - let now = crate::now(); + let note = insert(&state.db, &input.body).await?; + 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( "INSERT INTO scratch_notes (body, created_at, updated_at) VALUES (?1, ?2, ?2) RETURNING id, body, created_at, updated_at", ) - .bind(&input.body) - .bind(now) - .fetch_one(&state.db) + .bind(body) + .bind(crate::now()) + .fetch_one(db) .await?; - state.backup.signal(); - Ok(Json(row_to_note(&row))) + Ok(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( diff --git a/web/src/lib/CommandLine.svelte b/web/src/lib/CommandLine.svelte index 2e09165..913f266 100644 --- a/web/src/lib/CommandLine.svelte +++ b/web/src/lib/CommandLine.svelte @@ -52,6 +52,25 @@ let input = $state<HTMLInputElement>() let list = $state<HTMLUListElement>() 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') /* @@ -129,7 +148,7 @@ const query = line.slice(1) await workspace.guard(async () => { 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() return @@ -187,7 +206,7 @@ workspace.move(event.shiftKey ? -1 : 1) } else if (event.key === 'Tab') { 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')) { // The results, walked without leaving the line; Enter opens the one lit. event.preventDefault() @@ -227,6 +246,7 @@ <form onsubmit={submit}> {#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" /> </form> </div> diff --git a/web/src/lib/Scratch.svelte b/web/src/lib/Scratch.svelte index 9ab1296..d22d770 100644 --- a/web/src/lib/Scratch.svelte +++ b/web/src/lib/Scratch.svelte @@ -9,7 +9,7 @@ import Keys from './Keys.svelte' import { renderMarkdown } from './markdown' import { workspace } from './store.svelte' - import { relative } from './util' + import { headline, relative } from './util' let list = $state<HTMLUListElement>() @@ -21,11 +21,6 @@ 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) { if (workspace.scratchEditing) return workspace.scratchCursor = index diff --git a/web/src/lib/Sidebar.svelte b/web/src/lib/Sidebar.svelte index 763236a..5b10b7e 100644 --- a/web/src/lib/Sidebar.svelte +++ b/web/src/lib/Sidebar.svelte @@ -3,7 +3,7 @@ import { carry, into, release } from './carry' import Icon from './Icon.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>() /** 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="count"> {workspace.nodes.length} {workspace.nodes.length === 1 ? 'item' : 'items'}{#if workspace.foundCards.length > 0} - · {workspace.foundCards.length} {workspace.foundCards.length === 1 ? 'card' : 'cards'}{/if} + · {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> </div> @@ -162,6 +163,20 @@ </button> </li> {/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} <li class="empty muted"> {#if workspace.query} @@ -266,6 +281,7 @@ .row.open.active .dot { background: var(--ink); } /* A card is told from a node by its square. */ .row.card .dot { border: 1px solid currentColor; border-radius: 1px; } + .row.note .dot { border: 1px dashed currentColor; background: none; } .row.card.done .title { color: var(--muted); text-decoration: line-through; } .title { diff --git a/web/src/lib/actions.ts b/web/src/lib/actions.ts index 80fd8d7..eb730da 100644 --- a/web/src/lib/actions.ts +++ b/web/src/lib/actions.ts @@ -292,6 +292,11 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = { new: () => workspace.newScratchNote(), write: () => workspace.editScratchNote(), 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: () => { if (!workspace.scratchNote) return void (async () => { diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index b9cb8ca..345aef7 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -105,7 +105,7 @@ export interface ApiToken { /** Where `POST /api/capture` put the text. */ export interface Captured { - to: 'card' | 'diary' + to: 'card' | 'diary' | 'note' | 'document' id: number where: string } @@ -608,8 +608,15 @@ export const api = { }, ) => request<Moved>(`/cards/${id}`, { method: 'PUT', body: body(patch) }), - /** The scratchpad's notes, the one touched last first. */ - scratch: () => request<ScratchNote[]>('/scratch'), + /** The scratchpad's notes, the one touched last first — or those a search finds, the best first. */ + 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 }) }), updateScratch: (id: number, text: string) => request<ScratchNote>(`/scratch/${id}`, { method: 'PUT', body: body({ body: text }) }), diff --git a/web/src/lib/commands.ts b/web/src/lib/commands.ts index 5ea785e..193f6eb 100644 --- a/web/src/lib/commands.ts +++ b/web/src/lib/commands.ts @@ -189,6 +189,34 @@ const spaceNames = () => workspace.spaces.map((space) => space.slug) /** The capture tokens' names, for Tab after `:token rm`; null until fetched. */ 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. */ let templateCache: { space: number; names: string[] } | null = null @@ -547,6 +575,19 @@ export const commands: CommandSpec[] = [ 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', args: '[title]', @@ -697,9 +738,11 @@ export const commands: CommandSpec[] = [ }, { name: 'today', + args: '[text]', group: 'documents', - help: "open today's diary entry, starting one if there is none yet", - run: async () => { + 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 (arg) => { + if (arg.trim()) return workspace.guard(() => workspace.logToday(arg.trim())) await workspace.guard(() => workspace.openDay()) hooks.focusEditor() }, diff --git a/web/src/lib/keymap.ts b/web/src/lib/keymap.ts index 19f29c6..b8472fe 100644 --- a/web/src/lib/keymap.ts +++ b/web/src/lib/keymap.ts @@ -113,7 +113,7 @@ export const bindings = [ { context: 'tree', keys: [']d'], action: 'nextEntry' }, { context: 'global', keys: ['Ctrl-p'], action: 'find', label: 'Ctrl-P', help: 'find anything, in every space — Enter opens the result lit beside them, n goes on' }, { context: 'global', keys: ['b'], action: 'board', help: "the board, in a tile: this space's, or the last one shown — 1 … 9 there shows another's" }, - { context: 'tree', keys: ['c'], action: 'capture', help: "capture a card into this space's inbox, without leaving here — 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: ['/'], 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' }, @@ -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: ['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: ['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: [':'], action: 'line' }, { context: 'drawer', keys: ['?'], action: 'help' }, diff --git a/web/src/lib/store.svelte.ts b/web/src/lib/store.svelte.ts index 9f63d15..fc40dbd 100644 --- a/web/src/lib/store.svelte.ts +++ b/web/src/lib/store.svelte.ts @@ -139,8 +139,9 @@ class Workspace { /** Children of the current folder, or the results of a search. */ nodes = $state<NodeSummary[]>([]) - /** The cards a search found, listed after the nodes. */ + /** The cards a search found, listed after the nodes, and the notes after them. */ foundCards = $state<AgendaCard[]>([]) + foundNotes = $state<ScratchNote[]>([]) cursor = $state(0) query = $state('') @@ -446,9 +447,9 @@ class Workspace { 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 { - 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. */ @@ -456,6 +457,12 @@ class Workspace { 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, * 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 if (this.query) { const space = this.#searchSpace - ;[this.nodes, this.foundCards] = await Promise.all([api.listNodes({ q: this.query, space }), api.searchCards(this.query, space)]) + // 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 { this.nodes = await api.listNodes({ parent: this.parentId ?? undefined }) this.foundCards = [] + this.foundNotes = [] } const index = this.nodes.findIndex((n) => n.id === keepId) 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) { const found = this.foundCard if (found) return this.turnTo(found) + const note = this.foundNote + if (note) return this.showNote(note.id) const selected = this.selected if (!selected) return // 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 * 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') { const list = this.list 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.#reloadCardsAbout() 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') const inbox = (await api.board(space.id)).lists[0] 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.#reloadCardsAbout() this.say(`carded "${card.title}" → ${space.slug}/${inbox.name}`) @@ -1993,11 +2008,91 @@ class Workspace { async dropScratchNote(say = true, note = this.scratchNote) { if (!note) return await api.deleteScratch(note.id) - this.scratchNotes = this.scratchNotes.filter((n) => n.id !== note.id) - this.scratchCursor = Math.min(this.scratchCursor, Math.max(this.scratchNotes.length - 1, 0)) + this.#forgetNote(note.id) 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. */ async scratch(text: string) { 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. */ async keepScratchNote(title = '') { - if (this.scratchEditing && !(await this.leaveWriting())) return - const note = this.scratchNote - if (!note) return this.say('no scratch note to keep', 'error') + const note = await this.#leavingNote() + if (!note) return 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') - let name = title.trim() - let body = note.body - if (!name) { - const [first, ...rest] = note.body.replace(/^\s+/, '').split('\n') - name = first.replace(/^#+\s*/, '').trim().slice(0, 200) - body = rest.join('\n').replace(/^\n+/, '') - } + const split = this.#split(note) + const name = title.trim() || split.title + const body = title.trim() ? note.body : split.rest const node = await api.createNode({ parent_id: parent, name, body }) // Made first, dropped after: a failure in between leaves two copies, never none. await this.dropScratchNote(false, note) diff --git a/web/src/lib/util.ts b/web/src/lib/util.ts index c53ac94..6667b64 100644 --- a/web/src/lib/util.ts +++ b/web/src/lib/util.ts @@ -387,3 +387,8 @@ export function urgency(card: { due_at: number | null; done_at: number | null }) if (card.due_at < start) return 'overdue' 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() ?? '' +}