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
+557 -80

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 |
| `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 <title>` 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 |
+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 }` |
| `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 |
+8 -2
View File
@@ -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"}`.
+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 |
| `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 |
+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
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)
+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
`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.
+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 |
| `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.
+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_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");
+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.
async fn path_text(db: &mut SqliteConnection, id: i64) -> AppResult<String> {
let slugs: Vec<String> = sqlx::query_scalar(
+65 -15
View File
@@ -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(
#[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?;
.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(
+22 -2
View File
@@ -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>
+1 -6
View File
@@ -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
+18 -2
View File
@@ -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 {
+5
View File
@@ -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 () => {
+10 -3
View File
@@ -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 }) }),
+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. */
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()
},
+3 -1
View File
@@ -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' },
+109 -19
View File
@@ -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)
+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'
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() ?? ''
}