fixes: what the audit turned up

- POST /api/diary/day finds a day's entry, or starts it at the root of the
  diary, by the server's clock. T, the calendar and a capture all use it, so
  they can no longer write two entries for one day: before, T counted days by
  the browser's midnight and wrote into whichever diary folder was last open.
- A Ctrl-P result opens beside the results, as a / result does, so n and N
  go on from it rather than finding no search.
- A card's description resolves its [[links]] the way a document does, and
  following one leaves the card for what it leads to.
- gf on the board opens a card's notes. Enter was documented for that, but
  Enter turns the card over.
- The pane header and the status line show where the open node is, rather
  than where the list is, so a node from another space shows its own path.
This commit is contained in:
nvrl committed 2026-10-22 03:10:49 +02:00
1 parent ac3ad166e5
commit 7f8a3816d3
22 files changed
+312 -105

No files matched your search

+3 -1
View File
@@ -151,7 +151,7 @@ it; in the document pane it is that document. In the browse pane:
| `Esc`, `q` | close the document, and its tile when there are several |
| `h` | up one level in the tree |
| `/` | full-text search in this space (`:noh` clears it); `Tab` or `Ctrl-N`/`Ctrl-P` walk the results without leaving the line, `Enter` opens the one lit |
| `Ctrl-P` | full-text search across every space; `Enter` goes to the match |
| `Ctrl-P` | full-text search across every space; `Enter` opens the match beside the results |
| `n` / `N` | the next / previous search result, opened beside the results; past the last it starts again at the first |
| `s` / `y` | toggle sharing / mint a new share link — either asks first when it would cut readers off |
| `x`, `dd` | delete the entry under the cursor into the trash (asks `y`/`N`) |
@@ -271,6 +271,7 @@ you were. On the board, `o` or `c` puts a card on the column under the cursor.
| `J` / `K` | move the card down / up its list |
| `H` / `L` | move the card to the list on the left / right |
| `Enter` | turn the card over: labels, a markdown description, a checklist |
| `gf` | the card's notes, a document made on first use; `q` comes back |
| `Space` | into the done column, or back out to the inbox |
| `o`, `c` | a new card on this list |
| `D` | due date — `fri`, `tm`, `+3d`, `+2w`, `10-31`, `2026-10-31` |
@@ -869,6 +870,7 @@ sealed or not.
| `GET` | `/api/spaces/{id}/board` | the lists and cards of one space |
| `GET` | `/api/agenda` | open cards due within a week or in hand, on every board, whether the diary has today, its entries `on_this_day` in earlier years, and `today` in `WORKSPACE_TIMEZONE`, with its bounds |
| `GET` | `/api/diary/days?month=2026-10` | the diary's entries in a month, each with its local `day` |
| `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 |
+1
View File
@@ -22,6 +22,7 @@ sealed or not.
| `GET` | `/api/spaces/{id}/board` | the lists and cards of one space |
| `GET` | `/api/agenda` | open cards due within a week or in hand, on every board, whether the diary has today, its entries `on_this_day` in earlier years, and `today` in `WORKSPACE_TIMEZONE`, with its bounds |
| `GET` | `/api/diary/days?month=2026-10` | the diary's entries in a month, each with its local `day` |
| `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 |
+2 -1
View File
@@ -58,6 +58,7 @@ a week fold away until `z` shows them.
| `Space` | into the done list, or back out to the inbox |
| `o`, `c` | a new card on this list |
| `Enter` | turn the card over: labels, description, checklist |
| `gf` | the card's notes, without turning it over |
| `D` | due date |
| `R` | rename the card |
| `z` | show done cards older than a week |
@@ -106,7 +107,7 @@ you stay where you were. From outside the browser, see [Capture](Capture.md).
### Notes
`gf` on the back of a card opens its notes: a document named after the card, made in the
`gf` on a card, or on its back, opens its notes: a document named after the card, made in the
root of the space the first time. Closing it goes back to the board, on the
same card. `:card!` while reading a document makes a card that points at it.
+3 -2
View File
@@ -48,7 +48,7 @@ arguments and `↑` / `↓` recall earlier lines.
| `T` | today's diary entry, started if there is none — from templates/day when the diary has one |
| `C` | the diary by month, in the special workspace: h/l a day, j/k a week, H/L a month, . today, Enter opens or starts the day |
| `[d / ]d` | the diary entry before / after the one open, by date |
| `Ctrl-P` | find anything, in every space — Enter goes to the result lit |
| `Ctrl-P` | find anything, in every space — Enter opens the result lit beside them, n goes on |
| `b` | the board, in the special workspace: 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 |
| `Esc · q` | close the document, and its tile when there are several |
@@ -77,6 +77,7 @@ The first list is the inbox, the last list is done.
| `H / L` | move the card to the list left / right |
| `Space` | into the done list, or back out to the inbox |
| `Enter` | turn the card over: its labels, description and checklist |
| `gf` | the card's notes — a document, made on first use; q comes back |
| `o · c` | new card on this list |
| `D` | due date: fri, tm, +3d, 10-31 … |
| `R` | rename the card, or the list when it is empty |
@@ -127,7 +128,7 @@ do: `:w!`, `:space! <name>`, `:import!`.
| `:board` | this space's board, in the special workspace |
| `:special` | the special workspace: the board, the agenda and the calendar side by side — S puts it up and away, Tab walks its tiles, 1 … 9 picks the board |
| `:card, :c <title>` | add a card — on the board to the list under the cursor, elsewhere to this space's inbox |
| `:card!` | add a card linked to the open document — Enter on it opens the document |
| `:card!` | add a card linked to the open document — gf on it opens the document |
| `:due <when\|->` | due date of the card — on the board, or highlighted in the agenda: today, tm, fri, +3d, +2w, 10-31, 2026-10-31 — - clears |
| `:repeat <rule\|->` | once the card is done, a copy comes back in the inbox: daily, weekly, monthly, yearly, +3d, +2w, +6m — - stops it |
| `:done` | move the selected card into the done list, or back out to the inbox |
+3 -2
View File
@@ -93,8 +93,9 @@ shows the board of another space.
The first list is the inbox and the last is done; the ones between are work in
hand. `h`/`l` move between lists and `j`/`k` between cards. `H`/`L` move a
card across, `Space` finishes it, `D` sets its due date, and `Enter` opens its
notes: a document made for the card the first time you ask. `:repeat weekly`
card across, `Space` finishes it, and `D` sets its due date. `Enter` turns a
card over to its labels, description and checklist, and `gf` opens its notes:
a document made for the card the first time you ask. `:repeat weekly`
makes a card come back in the inbox each time it is finished. `q` goes back to
the tree.
+1 -1
View File
@@ -490,7 +490,7 @@ pub(crate) fn local_day(tz: Tz, at: i64) -> NaiveDate {
/// DST makes this a question with two awkward answers: the hour that occurs
/// twice, where the earlier one is meant, and the hour that does not occur at
/// all, where the clock skipping past it counts as the time having passed.
fn local_instant(tz: Tz, day: NaiveDate, hour: u32, minute: u32) -> i64 {
pub(crate) fn local_instant(tz: Tz, day: NaiveDate, hour: u32, minute: u32) -> i64 {
let Some(naive) = day.and_hms_opt(hour, minute, 0) else {
return i64::MAX;
};
+59 -8
View File
@@ -20,7 +20,7 @@ use axum::{
extract::{Path, State},
Json,
};
use std::collections::HashMap;
use std::collections::{BTreeMap, HashMap};
use chrono_tz::Tz;
use serde::{Deserialize, Serialize};
@@ -30,6 +30,7 @@ use crate::{
auth::Session,
error::{AppError, AppResult},
routes::{
links,
manifest::ListMeta,
nodes::{DOCUMENT, SPACE},
repeat::Repeat,
@@ -75,6 +76,8 @@ pub struct Card {
pub position: i64,
pub created_at: i64,
pub updated_at: i64,
/// Where each `[[title]]` in the description leads, as a document's do.
pub wikilinks: BTreeMap<String, String>,
}
/// A step on a card's checklist.
@@ -385,6 +388,7 @@ fn row_to_card(row: &sqlx::sqlite::SqliteRow) -> Card {
position: row.get("position"),
created_at: row.get("created_at"),
updated_at: row.get("updated_at"),
wikilinks: BTreeMap::new(),
}
}
@@ -633,6 +637,11 @@ async fn load(state: &AppState, space_id: i64, space_name: String) -> AppResult<
.fetch_all(&state.db)
.await?;
let mut cards: Vec<Card> = card_rows.iter().map(row_to_card).collect();
let mut conn = state.db.acquire().await?;
for card in cards.iter_mut().filter(|card| card.body.contains("[[")) {
card.wikilinks = links::wikilinks_in(&mut conn, Some(space_id), &card.body).await?;
}
let lists = list_rows
.iter()
.map(|row| {
@@ -641,11 +650,7 @@ async fn load(state: &AppState, space_id: i64, space_name: String) -> AppResult<
id,
name: row.get("name"),
position: row.get("position"),
cards: card_rows
.iter()
.filter(|card| card.get::<i64, _>("list_id") == id)
.map(row_to_card)
.collect(),
cards: cards.extract_if(.., |card| card.list_id == id).collect(),
}
})
.collect();
@@ -1187,12 +1192,58 @@ async fn card(db: &SqlitePool, id: i64) -> AppResult<Card> {
#[cfg(test)]
mod tests {
use super::{
agenda_cards, card, done_after_move, relocate, reordered, restore, settle, tidy_checklist,
tidy_labels, Task,
agenda_cards, card, done_after_move, load, relocate, reordered, restore, settle,
tidy_checklist, tidy_labels, Task,
};
use crate::routes::manifest::{CardMeta, ListMeta};
use std::collections::HashMap;
#[tokio::test]
async fn a_description_links_by_title_as_a_document_does() {
let state = crate::routes::tests::state_in(".".into()).await;
let db = &state.db;
let work: i64 = sqlx::query_scalar("SELECT id FROM nodes WHERE slug = 'work'")
.fetch_one(db)
.await
.unwrap();
sqlx::query(
"INSERT INTO nodes (parent_id, kind, name, slug, body, created_at, updated_at)
VALUES (?1, 'document', 'Runbook', 'runbook', '', 0, 0)",
)
.bind(work)
.execute(db)
.await
.unwrap();
let list: i64 = sqlx::query_scalar(
"INSERT INTO board_lists (space_id, name, position, created_at)
VALUES (?1, 'inbox', 0, 0) RETURNING id",
)
.bind(work)
.fetch_one(db)
.await
.unwrap();
sqlx::query(
"INSERT INTO cards (list_id, title, body, position, created_at, updated_at)
VALUES (?1, 'rotate', 'see [[Runbook]], then [[Nowhere]]', 0, 0, 0)",
)
.bind(list)
.execute(db)
.await
.unwrap();
let board = load(&state, work, "work".into()).await.unwrap();
let card = board
.lists
.iter()
.flat_map(|list| &list.cards)
.find(|card| card.list_id == list)
.unwrap();
assert_eq!(
card.wikilinks.iter().collect::<Vec<_>>(),
[(&"Runbook".to_string(), &"work/runbook".to_string())]
);
}
/// A space with a board of `inbox`, `doing`, `done` on a fresh database.
async fn board() -> (sqlx::SqlitePool, i64, Vec<i64>) {
let db = sqlx::sqlite::SqlitePoolOptions::new()
+10 -49
View File
@@ -14,9 +14,7 @@ use crate::{
error::{AppError, AppResult},
routes::{
boards::{self, CardInput},
diary, links, media,
nodes::{self, slugify, unique_slug},
revisions,
diary, links, media, nodes, revisions,
},
state::AppState,
};
@@ -94,35 +92,20 @@ async fn card(db: &SqlitePool, text: &str, space: Option<&str>) -> AppResult<Cap
})
}
/// `- 14:05 text`, at the end of today's entry: the first document written
/// today at the top of the diary, the one `:today` opens. With none yet, the
/// line starts one the way `:today` would, from `templates/day` if there is one.
/// `- 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.
async fn diary(db: &SqlitePool, tz: Tz, text: &str, now: i64) -> AppResult<Captured> {
let line = format!("- {} {}", clock(tz, now), item(text));
let mut tx = crate::db::begin_write(db).await?;
let diary: i64 = sqlx::query_scalar("SELECT id FROM nodes WHERE role = ?1")
.bind(nodes::DIARY)
.fetch_optional(&mut *tx)
.await?
.ok_or_else(|| AppError::BadRequest("there is no diary to write in".into()))?;
let (start, end) = crate::notify::day_bounds(tz, crate::notify::local_day(tz, now));
let today: Option<(i64, String)> = sqlx::query_as(
"SELECT id, body FROM nodes
WHERE parent_id = ?1 AND kind = 'document' AND created_at >= ?2 AND created_at < ?3
ORDER BY created_at, id
LIMIT 1",
)
.bind(diary)
.bind(start)
.bind(end)
.fetch_optional(&mut *tx)
.await?;
let id = match today {
Some((id, body)) => {
let body = appended(&body, &line);
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?;
}
sqlx::query("UPDATE nodes SET body = ?1, updated_at = ?2 WHERE id = ?3")
.bind(&body)
.bind(now)
@@ -131,28 +114,6 @@ async fn diary(db: &SqlitePool, tz: Tz, text: &str, now: i64) -> AppResult<Captu
.await?;
media::link_to_node(&mut tx, id, &body).await?;
links::index(&mut tx, id, &body).await?;
id
}
None => {
let start = diary::day_template(&mut tx, tz, diary, now).await?;
let body = appended(start.as_deref().unwrap_or_default(), &line);
let slug = unique_slug(&mut tx, Some(diary), &slugify(""), None).await?;
let id: i64 = sqlx::query_scalar(
"INSERT INTO nodes (parent_id, kind, name, slug, body, created_at, updated_at)
VALUES (?1, 'document', '', ?2, ?3, ?4, ?4)
RETURNING id",
)
.bind(diary)
.bind(&slug)
.bind(&body)
.bind(now)
.fetch_one(&mut *tx)
.await?;
media::link_to_node(&mut tx, id, &body).await?;
links::index(&mut tx, id, &body).await?;
id
}
};
tx.commit().await?;
let place = nodes::path_of(db, id)
+152 -3
View File
@@ -18,8 +18,11 @@ use sqlx::{Row, SqliteConnection, SqlitePool};
use crate::{
auth::Session,
error::{AppError, AppResult},
notify::{day_bounds, local_day, weekday_name},
routes::nodes::{excerpt, path_of, HEAD_CHARS},
notify::{day_bounds, local_day, local_instant, weekday_name},
routes::{
links, media,
nodes::{excerpt, path_of, slugify, unique_slug, HEAD_CHARS},
},
state::AppState,
};
@@ -321,6 +324,109 @@ pub(crate) async fn is_diary(conn: &mut SqliteConnection, id: i64) -> AppResult<
Ok(role.flatten().as_deref() == Some(crate::routes::nodes::DIARY))
}
/// The entry about the local day `day`: the first written, wherever in the
/// diary it was filed. With none, a new one at the root of the diary, from
/// `templates/day` when there is one, dated `at` — or, for another day than
/// the one `at` falls on, noon of it. `T`, the calendar and a capture all ask
/// this, so they find and start the same entry, by the server's clock.
pub(crate) async fn entry_of(
conn: &mut SqliteConnection,
tz: Tz,
day: NaiveDate,
at: i64,
) -> AppResult<Day> {
let diary: i64 = sqlx::query_scalar("SELECT id FROM nodes WHERE role = ?1")
.bind(crate::routes::nodes::DIARY)
.fetch_optional(&mut *conn)
.await?
.ok_or_else(|| AppError::BadRequest("there is no diary to write in".into()))?;
let (start, end) = day_bounds(tz, day);
let found: Option<(i64, String)> = sqlx::query_as(&format!(
"WITH RECURSIVE {ENTRIES}
SELECT id, body FROM nodes
WHERE kind = 'document' AND id IN (SELECT id FROM entries)
AND created_at >= ?1 AND created_at < ?2
ORDER BY created_at, id
LIMIT 1"
))
.bind(start)
.bind(end)
.fetch_optional(&mut *conn)
.await?;
if let Some((id, body)) = found {
return Ok(Day {
id,
body,
created: false,
});
}
let at = if local_day(tz, at) == day {
at
} else {
local_instant(tz, day, 12, 0)
};
let body = day_template(conn, tz, diary, at).await?.unwrap_or_default();
let slug = unique_slug(&mut *conn, Some(diary), &slugify(""), None).await?;
let id: i64 = sqlx::query_scalar(
"INSERT INTO nodes (parent_id, kind, name, slug, body, created_at, updated_at)
VALUES (?1, 'document', '', ?2, ?3, ?4, ?5)
RETURNING id",
)
.bind(diary)
.bind(&slug)
.bind(&body)
.bind(at)
.bind(crate::now())
.fetch_one(&mut *conn)
.await?;
media::link_to_node(conn, id, &body).await?;
links::index(conn, id, &body).await?;
Ok(Day {
id,
body,
created: true,
})
}
/// A day's entry, and whether asking for it is what started it.
#[derive(Serialize, Debug)]
pub struct Day {
pub id: i64,
#[serde(skip)]
pub body: String,
pub created: bool,
}
#[derive(Deserialize)]
pub struct DayInput {
/// `2026-10-22`; today, in `WORKSPACE_TIMEZONE`, without it.
#[serde(default)]
pub day: Option<String>,
}
/// `POST /api/diary/day`: the entry of a day, started when there is none.
pub async fn day(
_: Session,
State(state): State<AppState>,
Json(input): Json<DayInput>,
) -> AppResult<Json<Day>> {
let tz = state.config.mail.timezone;
let now = crate::now();
let day = match input.day.as_deref() {
Some(day) => NaiveDate::parse_from_str(day, "%Y-%m-%d")
.map_err(|_| AppError::BadRequest(format!("not a day: {day}, but 2026-10-22 is")))?,
None => local_day(tz, now),
};
let mut tx = crate::db::begin_write(&state.db).await?;
let entry = entry_of(&mut tx, tz, day, now).await?;
tx.commit().await?;
if entry.created {
state.backup.signal();
}
Ok(Json(entry))
}
#[cfg(test)]
mod tests {
use axum::{extract::State, Json};
@@ -328,7 +434,7 @@ mod tests {
use chrono_tz::Europe::Berlin;
use sqlx::SqlitePool;
use super::{around, fill, month_entries, on_this_day};
use super::{around, entry_of, fill, month_entries, on_this_day};
use crate::{auth::Session, routes::nodes::CreateInput, state::AppState};
fn at(y: i32, mo: u32, d: u32, h: u32, mi: u32) -> i64 {
@@ -459,6 +565,49 @@ mod tests {
assert_eq!(body, "## Thursday\n\n- 07:00 rain\n");
}
#[tokio::test]
async fn a_day_has_one_entry_found_by_the_servers_clock() {
let state = crate::routes::tests::state_in(".".into()).await;
let db = &state.db;
let diary = diary(db).await;
// 23:30 in UTC on the 21st is already the 22nd here.
let now = at(2026, 10, 22, 1, 30);
let day = |d| chrono::NaiveDate::from_ymd_opt(2026, 10, d).unwrap();
// An entry filed in a folder is still the day's.
let folder = add(db, diary, "folder", "october", "", 0).await;
let filed = add(db, folder, "document", "", "", at(2026, 10, 22, 0, 10)).await;
let templates = add(db, diary, "folder", "templates", "", 0).await;
add(db, templates, "document", "day", "{{weekday}}\n", 0).await;
let mut conn = db.acquire().await.unwrap();
let found = entry_of(&mut conn, Berlin, day(22), now).await.unwrap();
assert_eq!((found.id, found.created), (filed, false));
// A day without one gets one at the root, at noon, from the template.
let started = entry_of(&mut conn, Berlin, day(20), now).await.unwrap();
assert!(started.created);
assert_eq!(started.body, "Tuesday\n");
let (parent, created_at): (i64, i64) =
sqlx::query_as("SELECT parent_id, created_at FROM nodes WHERE id = ?1")
.bind(started.id)
.fetch_one(&mut *conn)
.await
.unwrap();
assert_eq!((parent, created_at), (diary, at(2026, 10, 20, 12, 0)));
// Asked again, it is found rather than started twice.
let again = entry_of(&mut conn, Berlin, day(20), now).await.unwrap();
assert_eq!((again.id, again.created), (started.id, false));
// Today's is dated now, and a capture writes into the same one.
let today = entry_of(&mut conn, Berlin, day(21), at(2026, 10, 21, 9, 0))
.await
.unwrap();
drop(conn);
let captured =
crate::routes::capture::tests::diary_at(db, "rain", at(2026, 10, 21, 18, 0)).await;
assert_eq!(captured, today.id);
}
#[tokio::test]
async fn a_month_holds_the_days_of_the_timezone() {
let state = crate::routes::tests::state_in(".".into()).await;
+11 -1
View File
@@ -431,6 +431,17 @@ pub async fn wikilinks(
db: &mut SqliteConnection,
from: i64,
body: &str,
) -> AppResult<BTreeMap<String, String>> {
let space = space_of(db, from).await?;
wikilinks_in(db, space, body).await
}
/// Where the `[[titles]]` in `body` lead, read from the space `space`: a
/// card's description, which is in a space without being a node in it.
pub async fn wikilinks_in(
db: &mut SqliteConnection,
space: Option<i64>,
body: &str,
) -> AppResult<BTreeMap<String, String>> {
let mut found = BTreeMap::new();
let titles: Vec<String> = written(body)
@@ -441,7 +452,6 @@ pub async fn wikilinks(
if titles.is_empty() {
return Ok(found);
}
let space = space_of(db, from).await?;
for title in titles {
if let Some(to) = resolve_title(db, &title, space).await? {
found.insert(title, path_text(db, to).await?);
+1
View File
@@ -62,6 +62,7 @@ pub fn api_router(max_upload: usize) -> Router<AppState> {
.route("/spaces/overview", get(nodes::overview))
.route("/agenda", get(boards::agenda))
.route("/diary/days", get(diary::days))
.route("/diary/day", post(diary::day))
.route("/diary/around/{id}", get(diary::around_entry))
.route("/capture", post(capture::capture))
.route("/scratch", get(scratch::list).post(scratch::create))
+6
View File
@@ -1106,6 +1106,12 @@
workspace.cardCursor = 0
return
}
// The card's notes, without turning it over first: `gf` as on its back.
if (previous === 'g' && key === 'f') {
event.preventDefault()
void workspace.guard(() => workspace.openCard())
return
}
if (previous === 'd' && key === 'd') {
event.preventDefault()
void runCommand('delete')
+14 -3
View File
@@ -5,6 +5,7 @@
* description, the checklist. The keys live in App.svelte with the others;
* this draws and lets a pointer do the same.
*/
import { followLink } from './commands'
import { renderMarkdown } from './markdown'
import { workspace } from './store.svelte'
import { formatDay, labelHue, urgency } from './util'
@@ -16,7 +17,7 @@
const lists = $derived(workspace.board?.lists.length ?? 0)
const role = $derived(lists < 2 ? '' : workspace.listCursor === 0 ? 'inbox' : workspace.listCursor === lists - 1 ? 'done' : '')
const ticked = $derived(card?.checklist.filter((task) => task.done).length ?? 0)
const html = $derived(card?.body.trim() ? renderMarkdown(card.body) : '')
const html = $derived(card?.body.trim() ? renderMarkdown(card.body, { wikilinks: card.wikilinks }) : '')
// A card deleted or moved out of sight under the sheet takes the sheet with it.
$effect(() => {
@@ -28,6 +29,16 @@
steps?.querySelector<HTMLElement>(`[data-index="${at}"]`)?.scrollIntoView({ block: 'nearest' })
})
/** A link in the description leaves the card for what it leads to, as one in a document does. */
function follow(event: MouseEvent) {
if (event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0) return
const href = (event.target as HTMLElement | null)?.closest('a')?.getAttribute('href') ?? ''
if (!href.startsWith('/n/')) return
event.preventDefault()
workspace.closeCardSheet()
followLink(href)
}
function pick(index: number) {
workspace.taskCursor = index
}
@@ -81,8 +92,8 @@
/>
{/await}
{:else if html}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<article class="md description" ondblclick={() => workspace.describe()}>
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions, a11y_no_noninteractive_element_interactions -->
<article class="md description" onclick={follow} ondblclick={() => workspace.describe()}>
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitised in renderMarkdown -->
{@html html}
</article>
+3 -2
View File
@@ -14,6 +14,7 @@
relative,
scrollPage,
scrollWide,
slugPath,
} from './util'
/** The tile this pane draws; the keys only reach it while it has focus. */
@@ -143,13 +144,13 @@
<section class="pane">
<header>
<div class="crumb label rule" title="{workspace.here}/{doc.slug}">
<div class="crumb label rule" title="{slugPath(doc.path)}">
<!-- A phone has no crumbs on the bar and no h key: this is the way back. -->
<button class="back" aria-label="back to the list" onclick={() => runCommand('q')}>
<Icon name="back" size={16} />
</button>
<span class="marker" class:live={tile.editing}></span>
<span class="path">{workspace.here}/{doc.slug}</span>
<span class="path">{slugPath(doc.path)}</span>
{#if tile.dirty}<span class="dirty">modified</span>{/if}
</div>
+3 -3
View File
@@ -5,7 +5,7 @@
import { renderMarkdown } from './markdown'
import { looksLikeText, parseTable, previewKind, separatorOf } from './preview'
import { workspace, type Tile } from './store.svelte'
import { formatBytes, formatStamp, headingsIn, jumpHeading, readerHeadings, relative, scrollPage, scrollWide } from './util'
import { formatBytes, formatStamp, headingsIn, jumpHeading, readerHeadings, relative, scrollPage, scrollWide, slugPath } from './util'
/** The tile this pane draws; the keys only reach it while it has focus. */
const { tile }: { tile: Tile } = $props()
@@ -158,12 +158,12 @@
<section class="pane">
<header>
<div class="crumb label rule" title="{workspace.here}/{node.slug}">
<div class="crumb label rule" title="{slugPath(node.path)}">
<button class="back" aria-label="back to the list" onclick={() => runCommand('q')}>
<Icon name="back" size={16} />
</button>
<span class="marker"></span>
<span class="path">{workspace.here}/{node.slug}</span>
<span class="path">{slugPath(node.path)}</span>
</div>
<div class="row">
+2 -1
View File
@@ -78,7 +78,7 @@
['T', "today's diary entry, started if there is none — from templates/day when the diary has one"],
['C', 'the diary by month, in the special workspace: h/l a day, j/k a week, H/L a month, . today, Enter opens or starts the day'],
['[d / ]d', 'the diary entry before / after the one open, by date'],
['Ctrl-P', 'find anything, in every space — Enter goes to the result lit'],
['Ctrl-P', 'find anything, in every space — Enter opens the result lit beside them, n goes on'],
['b', "the board, in the special workspace: 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"],
['Esc · q', 'close the document, and its tile when there are several'],
@@ -107,6 +107,7 @@
['H / L', 'move the card to the list left / right'],
['Space', 'into the done list, or back out to the inbox'],
['Enter', 'turn the card over: its labels, description and checklist'],
['gf', "the card's notes — a document, made on first use; q comes back"],
['o · c', 'new card on this list'],
['D', 'due date: fri, tm, +3d, 10-31 …'],
['R', 'rename the card, or the list when it is empty'],
+1 -1
View File
@@ -55,7 +55,7 @@
{ icon: 'right', label: 'Send the card right', keys: ['L'], run: () => workspace.guard(() => workspace.sendCard(1)), dim: !card },
{ icon: 'calendar', label: 'Due date', keys: ['D'], run: () => hooks.openCommandLine(':due '), dim: !card },
{ icon: 'repeat', label: 'Repeat once done', keys: [':repeat'], run: () => hooks.openCommandLine(':repeat '), dim: !card },
{ icon: 'doc', label: "The card's notes", keys: ['Enter'], run: () => workspace.guard(() => workspace.openCard()), dim: !card },
{ icon: 'doc', label: "The card's notes", keys: ['gf'], run: () => workspace.guard(() => workspace.openCard()), dim: !card },
],
[
{ icon: 'up', label: 'Card up its list', keys: ['K'], run: () => workspace.guard(() => workspace.nudgeCard(-1)), dim: !card },
+2 -2
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import Rail from './Rail.svelte'
import { workspace } from './store.svelte'
import { isTouchDevice } from './util'
import { isTouchDevice, slugPath } from './util'
/** A key waiting for its second half, like vim's `showcmd`. */
const { pending = '' }: { pending?: string } = $props()
@@ -12,7 +12,7 @@
// The path is the file name here: work/einarbeiten/01-was-ist-eebus.
const file = $derived(
workspace.open ? `${workspace.here}/${workspace.open.slug}` : `${workspace.here || '~'}/`,
workspace.open ? slugPath(workspace.open.path) : `${workspace.here || '~'}/`,
)
const lines = $derived(workspace.editing ? workspace.draft.body.split('\n').length : null)
+9
View File
@@ -176,6 +176,8 @@ export interface Card {
position: number
created_at: number
updated_at: number
/** Where each `[[title]]` in the description leads, as a document's do. */
wikilinks: Record<string, string>
}
/** A step on a card's checklist. */
@@ -492,6 +494,13 @@ export const api = {
/** A month's diary entries: `2026-10`. */
diaryDays: (month: string) => request<DiaryEntry[]>(`/diary/days?month=${encodeURIComponent(month)}`),
/**
* The entry of a day, `2026-10-22`, or of today on the server's clock;
* started when there is none, which `created` says.
*/
diaryDay: (day?: string) =>
request<{ id: number; created: boolean }>('/diary/day', { method: 'POST', body: body({ day }) }),
/** The entries before and after a diary entry, by date. */
diaryAround: (id: number) =>
request<{ prev: DiaryEntry | null; next: DiaryEntry | null }>(`/diary/around/${id}`),
+2 -18
View File
@@ -465,7 +465,7 @@ export const commands: CommandSpec[] = [
await workspace.guard(() => workspace.createCard(arg.trim()))
},
bang: {
help: 'add a card linked to the open document — Enter on it opens the document',
help: 'add a card linked to the open document — gf on it opens the document',
run: async (arg) => {
// On the board the tree is not on screen, so only the open document
// can be meant; elsewhere it is whatever has focus.
@@ -688,23 +688,7 @@ export const commands: CommandSpec[] = [
group: 'documents',
help: "open today's diary entry, starting one if there is none yet",
run: async () => {
await workspace.guard(async () => {
// The diary is one space among several now, so :today goes there first
// rather than writing the day into whatever folder was being browsed.
const home = workspace.spaces.find((space) => space.role === 'diary')
if (home && workspace.parentId !== home.id) await workspace.enterSpace(home.id)
else if (workspace.query) await workspace.search('')
const midnight = new Date()
midnight.setHours(0, 0, 0, 0)
const from = Math.floor(midnight.getTime() / 1000)
const today = workspace.nodes.find(
(node) =>
node.kind === 'document' && node.created_at >= from && node.created_at < from + 86400,
)
if (today) await workspace.openNode(today.id, true)
else await workspace.createDocument()
})
await workspace.guard(() => workspace.openDay())
hooks.focusEditor()
},
},
+21 -7
View File
@@ -981,11 +981,8 @@ class Workspace {
async openSelected(edit = false) {
const selected = this.selected
if (!selected) return
// A match from anywhere is gone to, not opened beside a list it is not in.
if (this.globalSearch) {
const node = await api.getNode(selected.id)
return this.openPath(node.path.map((crumb) => crumb.slug).join('/'))
}
// A match from anywhere opens beside the results, as one from this space
// does, so `n` goes on from it; only a folder ends the search, by going in.
if (isContainer(selected)) await this.goTo(selected.id)
else await this.openNode(selected.id, edit)
}
@@ -1383,10 +1380,27 @@ class Workspace {
const entry = this.calendarEntries.find((e) => e.day === day)
// Opened from the special workspace, so `q` on the entry comes back here.
if (entry) return this.openPath(entry.path)
await this.openDay(day)
}
/**
* `T`, and a day in the calendar without an entry: the server finds the
* day's entry or starts it, by its own clock and at the root of the diary,
* as a capture does, so the three never write two entries for one day.
*/
async openDay(day?: string) {
const home = this.spaces.find((space) => space.role === 'diary')
if (!home) return this.say('there is no diary to write in', 'error')
if (this.parentId !== home.id) await this.enterSpace(home.id)
await this.createDocument('', { created_at: parseDay(day) ?? undefined })
const entry = await api.diaryDay(day)
if (this.spaceId !== home.id) await this.enterSpace(home.id)
else if (this.query) await this.search('')
if (entry.created) await this.refresh()
await this.openNode(entry.id, true)
if (entry.created) {
// Left as it was started, it goes again, as a new document from `o` does.
this.tile.fresh = entry.id
this.enterInsert = true
}
}
/** `[d` / `]d`: the diary entry before or after the one being read. */
+3
View File
@@ -258,6 +258,9 @@ export function linkAt(line: string, column: number): string | null {
* Where a `[[title]]` points when the page cannot say where it leads: the
* title itself, for following it to look it up, or to offer to write it.
*/
/** `work/notes/plan`: where a node is, by its crumbs. */
export const slugPath = (path: { slug: string }[]) => path.map((crumb) => crumb.slug).join('/')
export const titleHref = (title: string) => `/n/?title=${encodeURIComponent(title)}`
/** What a share link's reader will miss: a share never serves a sealed file. */