mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
scratch: the scratchpad is the inbox, with ways out and a search
A note now leaves the scratchpad by becoming something else: `K` keeps it as a document, as before; `C` makes it a card where `:card` would put one, the first line its title; `A` opens `:append` with the document on screen filled in, Tab completing any other title, and puts the note at its end. `/api/capture` takes `to: "note"` and `to: "document"` (named as a link names it), and `note`, the scratch note the text came from, dropped once the text has landed. Appending keeps the revision before it and indexes links and media, as a save does. Migration 0028 adds `scratch_fts`; `GET /api/scratch?q=` searches it, and `/` lists note hits under "notes" after the cards, Enter lowering the drawer at the note. The `c` capture line cycles its target with Tab: a card, a note, or a timed line in today's entry, which `:today <text>` now writes.
This commit is contained in:
1 parent
ccc91028e7
commit
2233fa41a6
20 files changed
+561
-84
No files matched your search
@@ -148,7 +148,7 @@ it; in the document pane it is that document. In the browse pane:
|
||||
| `T` | today's diary entry, started if there is none |
|
||||
| `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
@@ -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 |
|
||||
|
||||
@@ -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"}`.
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
|
||||
+4
-1
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
-- The scratchpad is where things are written down before it is clear where
|
||||
-- they belong, which makes it the first place to look for something half
|
||||
-- remembered. Search finds a note the way it finds a document or a card.
|
||||
CREATE VIRTUAL TABLE scratch_fts USING fts5 (body, tokenize = 'unicode61');
|
||||
|
||||
CREATE TRIGGER scratch_ai AFTER INSERT ON scratch_notes BEGIN
|
||||
INSERT INTO scratch_fts (rowid, body) VALUES (new.id, new.body);
|
||||
END;
|
||||
CREATE TRIGGER scratch_ad AFTER DELETE ON scratch_notes BEGIN
|
||||
DELETE FROM scratch_fts WHERE rowid = old.id;
|
||||
END;
|
||||
CREATE TRIGGER scratch_au AFTER UPDATE OF body ON scratch_notes BEGIN
|
||||
DELETE FROM scratch_fts WHERE rowid = old.id;
|
||||
INSERT INTO scratch_fts (rowid, body) VALUES (new.id, new.body);
|
||||
END;
|
||||
|
||||
-- The notes written before the triggers existed.
|
||||
INSERT INTO scratch_fts (rowid, body) SELECT id, body FROM scratch_notes;
|
||||
+195
-18
@@ -7,14 +7,16 @@ use axum::{extract::State, Json};
|
||||
use chrono::{TimeZone, Utc};
|
||||
use chrono_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");
|
||||
|
||||
@@ -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(
|
||||
|
||||
+69
-19
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
},
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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() ?? ''
|
||||
}
|
||||
Reference in new issue
Block a user