diary: templates, a calendar, the days either side and this day before

A folder called templates at the root of any space holds templates. `:new
<template> [yyyy-mm-dd] [name]` starts a document from one, with {{date}},
{{weekday}}, {{time}} and {{title}} filled in on the server, in
WORKSPACE_TIMEZONE, for the date the document is for. A new diary entry starts
from templates/day when the diary has one, whether it comes from T, o, the
calendar or a capture. Templates are not entries: the calendar, the reminder
and "written today" leave them out.

C (or :cal) shows the diary by month: h/l a day, j/k a week, H/L a month, .
today, and Enter opens the day's entry or starts one dated that day. [d and
]d step to the entry before or after the open one by date, backdated ones
included. The agenda ends with what the diary holds from this day in earlier
years; a 29 February is remembered on the 28th in years without one.

New routes: GET /api/diary/days?month=, GET /api/diary/around/{id} and
GET /api/nodes/{id}/templates. POST /api/nodes takes template_id.
This commit is contained in:
nvrl committed 2026-10-20 23:41:04 +02:00
1 parent f629f5af97
commit 824f2bb945
14 files changed
+1048 -50

No files matched your search

+35 -4
View File
@@ -98,6 +98,8 @@ it; in the document pane it is that document. In the browse pane:
| `b` | the board of this space |
| `t` | the agenda: what is late, due this week, or in hand, on every board |
| `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 |
| `Esc`, `q` | close the document, and its tile when there are several |
| `h` | up one level in the tree |
@@ -113,7 +115,7 @@ it; in the document pane it is that document. In the browse pane:
Inside the editor the full vim keymap is live — `w`/`b`, `dd`, `ciw`, visual
mode, macros, `:w` to write. `:help` lists every ex command; the useful ones are
`:w`, `:wq`, `:q!`, `:new [yyyy-mm-dd]`, `:date`, `:name`, `:share`, `:link`,
`:w`, `:wq`, `:q!`, `:new [template] [yyyy-mm-dd] [name]`, `:date`, `:name`, `:share`, `:link`,
`:upload`, `:media`, `:search`, `:backup` and
`:set novim`. `:name` on its own moves up into the title; `Enter`, `Esc` or `↓`
there goes back down into the text. In the text, `]]`, `[[` and `gO` walk the
@@ -241,6 +243,32 @@ it stands, `D` sets its due date (`:due` on the line), `H` / `L` send it to
the list left / right on its board, and `T` writes today's entry. The bar shows `late` and `today` counts
whenever either is above zero, and the agenda opens by itself once a day when
something is late or due, so nothing has to be remembered to be looked at.
Below the cards it lists what the diary holds from this day in earlier years;
`j` walks on into them and `Enter` opens one. A 29 February is remembered on
the 28th in the years that have none.
## The diary
The space with the diary role is a journal: each document in it is an entry,
dated by the day it is about (`:date` changes that). `T` opens today's, `[d`
and `]d` step to the one before or after by date, and `C` (or `:cal`) shows a
month: `h` / `l` move a day, `j` / `k` a week, `H` / `L` a month, `.` goes
back to today, and `Enter` opens the day's entry or starts one dated that day.
A marked day has an entry, a number says how many. Days are counted in
`WORKSPACE_TIMEZONE`, the same as the evening reminder.
### Templates
Any space can have a folder called `templates` at its root; every document in
it is a template. `:new meeting` starts a document from `templates/meeting`,
`:new meeting 2026-10-20 Budget` dates and names it as well, and Tab completes
the template names. In a template, `{{date}}` becomes `2026-10-20`,
`{{weekday}}` `Tuesday`, `{{time}}` `14:05` and `{{title}}` the new document's
name, all on the clock of `WORKSPACE_TIMEZONE` at the date the document is
for. A new entry in the diary — from `T`, `o`, the calendar or a capture —
starts from `templates/day` when the diary has one. The templates are not
entries, so they are not on the calendar and do not count as having written
today.
## Capturing from elsewhere
@@ -264,7 +292,7 @@ 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. The answer says where it went: `{"to":"card","id":7,"where":"work/backlog"}`.
none yet, from `templates/day` when the diary has one. 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
@@ -736,13 +764,16 @@ sealed or not.
| `GET` | `/api/sessions` | every signed-in browser, the one seen last first |
| `DELETE` | `/api/sessions/{id}` | sign another browser out, and lock the vault |
| `GET`/`POST` | `/api/spaces` | the spaces / create one |
| `GET`/`POST` | `/api/nodes` | children (`?parent=`) or search (`?q=`, `?space=`) / create |
| `GET`/`POST` | `/api/nodes` | children (`?parent=`) or search (`?q=`, `?space=`) / create, from a template with `template_id` |
| `GET`/`PUT`/`DELETE` | `/api/nodes/{id}` | read / update / delete into the trash, subtree and all — or for good, for something already in it |
| `GET`/`DELETE` | `/api/trash` | what was deleted, the last first / empty it |
| `DELETE` | `/api/trash/{id}` | delete one thing in the trash for good |
| `POST` | `/api/trash/{id}/restore` | back where it was deleted from, or to its space when that folder is gone |
| `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, and whether the diary has today |
| `GET` | `/api/agenda` | open cards due within a week or in hand, on every board, whether the diary has today, and its entries `on_this_day` in earlier years |
| `GET` | `/api/diary/days?month=2026-10` | the diary's entries in a month, each with its local `day` |
| `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 |
| `GET`/`POST` | `/api/tokens` | the capture tokens / make one, `{ name }`, answered with the token this once |
| `DELETE` | `/api/tokens/{id}` | remove a capture token |
+6 -8
View File
@@ -461,16 +461,14 @@ fn device_hash(user_agent: &str) -> String {
/// whether today has been written.
pub(crate) async fn diary_written(db: &SqlitePool, tz: Tz, day: NaiveDate) -> sqlx::Result<bool> {
let (start, end) = day_bounds(tz, day);
let written: i64 = sqlx::query_scalar(
"WITH RECURSIVE tree (id) AS (
SELECT id FROM nodes WHERE role = 'diary'
UNION ALL
SELECT n.id FROM nodes n JOIN tree t ON n.parent_id = t.id
)
// A template is not an entry, however recently it was written.
let written: i64 = sqlx::query_scalar(&format!(
"WITH RECURSIVE {}
SELECT count(*) FROM nodes
WHERE kind = 'document' AND id IN (SELECT id FROM tree)
WHERE kind = 'document' AND id IN (SELECT id FROM entries)
AND created_at >= ? AND created_at < ?",
)
crate::routes::diary::ENTRIES
))
.bind(start)
.bind(end)
.fetch_one(db)
+9 -2
View File
@@ -140,6 +140,8 @@ pub struct Agenda {
/// Whether the diary has an entry for today, in the configured timezone —
/// `None` when there is no diary space to ask about.
pub diary_today: Option<bool>,
/// Diary entries from this day in earlier years, the latest year first.
pub on_this_day: Vec<crate::routes::diary::Entry>,
}
#[derive(Serialize)]
@@ -162,16 +164,21 @@ pub async fn agenda(_: Session, State(state): State<AppState>) -> AppResult<Json
let has_diary: Option<i64> = sqlx::query_scalar("SELECT id FROM nodes WHERE role = 'diary'")
.fetch_optional(&state.db)
.await?;
let tz = state.config.mail.timezone;
let diary_today = match has_diary {
Some(_) => {
let tz = state.config.mail.timezone;
let today = crate::notify::local_day(tz, crate::now());
Some(crate::notify::diary_written(&state.db, tz, today).await?)
}
None => None,
};
let on_this_day = crate::routes::diary::on_this_day(&state.db, tz, crate::now()).await?;
Ok(Json(Agenda { cards, diary_today }))
Ok(Json(Agenda {
cards,
diary_today,
on_this_day,
}))
}
/// The agenda's cards, due before `horizon` or in hand. Apart from the handler
+11 -4
View File
@@ -14,7 +14,7 @@ use crate::{
error::{AppError, AppResult},
routes::{
boards::{self, CardInput},
links, media,
diary, links, media,
nodes::{self, slugify, unique_slug},
revisions,
},
@@ -96,7 +96,7 @@ 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.
/// line starts one the way `:today` 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));
@@ -134,7 +134,8 @@ async fn diary(db: &SqlitePool, tz: Tz, text: &str, now: i64) -> AppResult<Captu
id
}
None => {
let body = format!("{line}\n");
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)
@@ -203,7 +204,7 @@ fn appended(body: &str, line: &str) -> String {
}
#[cfg(test)]
mod tests {
pub(crate) mod tests {
use axum::{
body::Body,
http::{header, Request, StatusCode},
@@ -218,6 +219,12 @@ mod tests {
const TOKEN: &str = "nwc_a-token-for-the-tests";
/// A line in the diary in Berlin at `now`, for tests elsewhere: the id of
/// the entry it went into.
pub(crate) async fn diary_at(db: &sqlx::SqlitePool, text: &str, now: i64) -> i64 {
diary(db, Berlin, text, now).await.unwrap().id
}
async fn state() -> AppState {
let state = crate::routes::tests::state_in(".".into()).await;
sqlx::query("INSERT INTO api_tokens (name, hash, created_at) VALUES ('phone', ?1, 0)")
+541
View File
@@ -0,0 +1,541 @@
//! The diary as a diary rather than a list: the days that have an entry, the
//! entry before and after the one being read, what was written on this day in
//! earlier years — and templates, which every space has, but which the diary
//! is the first to want.
//!
//! A day is a local day in `WORKSPACE_TIMEZONE`, the same one the evening
//! reminder asks about, so the calendar and the mail never disagree.
use axum::{
extract::{Path, Query, State},
Json,
};
use chrono::{Datelike, Months, NaiveDate, TimeZone, Utc};
use chrono_tz::Tz;
use serde::{Deserialize, Serialize};
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},
state::AppState,
};
/// The folder at the root of a space that holds its templates. Found by name,
/// as a person would look for it: `role` is for the one diary and the trash.
pub const TEMPLATES: &str = "templates";
/// The template a new diary entry starts from, when the diary has one.
pub const DAY: &str = "day";
/// The diary's entries: every document in the diary space except the
/// templates in its `templates` folder, which are about no day at all.
/// `WITH RECURSIVE {ENTRIES} SELECT … WHERE kind = 'document' AND id IN
/// (SELECT id FROM entries)`.
pub(crate) const ENTRIES: &str = "entries (id) AS (
SELECT id FROM nodes WHERE role = 'diary'
UNION ALL
SELECT n.id FROM nodes n JOIN entries e ON n.parent_id = e.id
WHERE NOT (n.kind = 'folder' AND n.slug = 'templates'
AND n.parent_id = (SELECT id FROM nodes WHERE role = 'diary'))
)";
#[derive(Serialize)]
pub struct Template {
pub id: i64,
pub name: String,
pub slug: String,
}
/// `{{date}}`, `{{weekday}}`, `{{time}}` and `{{title}}` in a template, for a
/// document dated `at`. The title goes in last, so a title that happens to
/// contain a placeholder is left as it was typed.
pub(crate) fn fill(text: &str, tz: Tz, at: i64, title: &str) -> String {
let local = Utc
.timestamp_opt(at, 0)
.single()
.unwrap_or_else(Utc::now)
.with_timezone(&tz);
text.replace("{{date}}", &local.format("%Y-%m-%d").to_string())
.replace("{{weekday}}", weekday_name(local.weekday()))
.replace("{{time}}", &local.format("%H:%M").to_string())
.replace("{{title}}", title)
}
/// The templates of the space `space_id`, by name.
pub(crate) async fn templates_of(
conn: &mut SqliteConnection,
space_id: i64,
) -> AppResult<Vec<Template>> {
let rows = sqlx::query(
"SELECT t.id, t.name, t.slug FROM nodes t
JOIN nodes f ON f.id = t.parent_id
WHERE f.parent_id = ?1 AND f.kind = 'folder' AND f.slug = ?2 AND t.kind = 'document'
ORDER BY t.slug",
)
.bind(space_id)
.bind(TEMPLATES)
.fetch_all(&mut *conn)
.await?;
Ok(rows
.iter()
.map(|row| Template {
id: row.get("id"),
name: row.get("name"),
slug: row.get("slug"),
})
.collect())
}
/// The body of the template `id`, filled in for a document called `title`
/// dated `at`.
pub(crate) async fn from_template(
conn: &mut SqliteConnection,
tz: Tz,
id: i64,
at: i64,
title: &str,
) -> AppResult<String> {
let body: Option<String> =
sqlx::query_scalar("SELECT body FROM nodes WHERE id = ?1 AND kind = 'document'")
.bind(id)
.fetch_optional(&mut *conn)
.await?;
let body = body.ok_or_else(|| AppError::BadRequest("a template is a document".into()))?;
Ok(fill(&body, tz, at, title))
}
/// The diary's `templates/day`, filled in for `at`, when there is one.
pub(crate) async fn day_template(
conn: &mut SqliteConnection,
tz: Tz,
diary: i64,
at: i64,
) -> AppResult<Option<String>> {
let day = templates_of(conn, diary)
.await?
.into_iter()
.find(|template| template.slug == DAY);
match day {
Some(template) => Ok(Some(from_template(conn, tz, template.id, at, "").await?)),
None => Ok(None),
}
}
/// `GET /api/nodes/{id}/templates`: the templates of the space `id` is in.
pub async fn templates(
_: Session,
State(state): State<AppState>,
Path(id): Path<i64>,
) -> AppResult<Json<Vec<Template>>> {
let path = path_of(&state.db, id).await?;
let space = path.first().ok_or(AppError::NotFound)?;
let mut conn = state.db.acquire().await?;
templates_of(&mut conn, space.id).await.map(Json)
}
#[derive(Deserialize)]
pub struct DaysQuery {
/// `2026-10`.
pub month: String,
}
/// An entry in the diary, as the calendar and the agenda show it.
#[derive(Serialize, Debug)]
pub struct Entry {
pub id: i64,
pub name: String,
/// The local day it is about: `2026-10-20`.
pub day: String,
pub created_at: i64,
/// `diary/untitled`, to open it by.
pub path: String,
pub excerpt: String,
}
async fn entries_between(db: &SqlitePool, tz: Tz, start: i64, end: i64) -> AppResult<Vec<Entry>> {
let rows = sqlx::query(&format!(
"WITH RECURSIVE {ENTRIES}
SELECT id, name, created_at, substr(body, 1, {HEAD_CHARS}) AS head 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"
))
.bind(start)
.bind(end)
.fetch_all(db)
.await?;
let mut entries = Vec::with_capacity(rows.len());
for row in rows {
let id: i64 = row.get("id");
let created_at: i64 = row.get("created_at");
entries.push(Entry {
id,
name: row.get("name"),
day: local_day(tz, created_at).format("%Y-%m-%d").to_string(),
created_at,
path: slug_path(db, id).await?,
excerpt: excerpt(row.get("head")),
});
}
Ok(entries)
}
async fn slug_path(db: &SqlitePool, id: i64) -> AppResult<String> {
Ok(path_of(db, id)
.await?
.iter()
.map(|crumb| crumb.slug.as_str())
.collect::<Vec<_>>()
.join("/"))
}
/// The entries of one month, from the first of it to the first of the next,
/// both in the configured timezone.
async fn month_entries(db: &SqlitePool, tz: Tz, month: &str) -> AppResult<Vec<Entry>> {
let first = NaiveDate::parse_from_str(&format!("{month}-01"), "%Y-%m-%d")
.map_err(|_| AppError::BadRequest(format!("not a month: {month}, but 2026-10 is")))?;
let next = first
.checked_add_months(Months::new(1))
.ok_or_else(|| AppError::BadRequest(format!("not a month: {month}")))?;
entries_between(db, tz, day_bounds(tz, first).0, day_bounds(tz, next).0).await
}
/// `GET /api/diary/days?month=2026-10`: the entries of a month, for the
/// calendar to mark its days with.
pub async fn days(
_: Session,
State(state): State<AppState>,
Query(query): Query<DaysQuery>,
) -> AppResult<Json<Vec<Entry>>> {
month_entries(&state.db, state.config.mail.timezone, &query.month)
.await
.map(Json)
}
#[derive(Serialize, Debug, Default)]
pub struct Around {
pub prev: Option<Entry>,
pub next: Option<Entry>,
}
/// The entries either side of `id` by date: `[d` and `]d`.
async fn around(db: &SqlitePool, tz: Tz, id: i64) -> AppResult<Around> {
let at: Option<i64> = sqlx::query_scalar(&format!(
"WITH RECURSIVE {ENTRIES}
SELECT created_at FROM nodes
WHERE id = ?1 AND kind = 'document' AND id IN (SELECT id FROM entries)"
))
.bind(id)
.fetch_optional(db)
.await?;
let at = at.ok_or_else(|| AppError::BadRequest("this is not a diary entry".into()))?;
let step = |before: bool| {
let (compare, order) = if before { ("<", "DESC") } else { (">", "ASC") };
format!(
"WITH RECURSIVE {ENTRIES}
SELECT id FROM nodes
WHERE kind = 'document' AND id IN (SELECT id FROM entries)
AND (created_at {compare} ?1 OR (created_at = ?1 AND id {compare} ?2))
ORDER BY created_at {order}, id {order}
LIMIT 1"
)
};
let mut found = Around::default();
for before in [true, false] {
let next: Option<i64> = sqlx::query_scalar(&step(before))
.bind(at)
.bind(id)
.fetch_optional(db)
.await?;
let Some(next) = next else { continue };
let created_at: i64 = sqlx::query_scalar("SELECT created_at FROM nodes WHERE id = ?1")
.bind(next)
.fetch_one(db)
.await?;
let entry = entries_between(db, tz, created_at, created_at + 1)
.await?
.into_iter()
.find(|entry| entry.id == next);
if before {
found.prev = entry;
} else {
found.next = entry;
}
}
Ok(found)
}
/// `GET /api/diary/around/{id}`.
pub async fn around_entry(
_: Session,
State(state): State<AppState>,
Path(id): Path<i64>,
) -> AppResult<Json<Around>> {
around(&state.db, state.config.mail.timezone, id)
.await
.map(Json)
}
/// What was written on this day in earlier years, the latest year first.
/// 29 February comes round only every fourth year; in the others it is
/// remembered on the 28th, so a leap day is not skipped for three years.
pub(crate) async fn on_this_day(db: &SqlitePool, tz: Tz, now: i64) -> AppResult<Vec<Entry>> {
let first: Option<i64> = sqlx::query_scalar(&format!(
"WITH RECURSIVE {ENTRIES}
SELECT MIN(created_at) FROM nodes
WHERE kind = 'document' AND id IN (SELECT id FROM entries)"
))
.fetch_one(db)
.await?;
let Some(first) = first else {
return Ok(Vec::new());
};
let today = local_day(tz, now);
let leap = NaiveDate::from_ymd_opt(today.year(), 2, 29).is_some();
let late_leap_day = !leap && today.month() == 2 && today.day() == 28;
let mut found = Vec::new();
for year in (local_day(tz, first).year()..today.year()).rev() {
let mut days = vec![NaiveDate::from_ymd_opt(year, today.month(), today.day())];
if late_leap_day {
days.push(NaiveDate::from_ymd_opt(year, 2, 29));
}
for day in days.into_iter().flatten() {
let (start, end) = day_bounds(tz, day);
found.extend(entries_between(db, tz, start, end).await?);
}
}
Ok(found)
}
/// Whether `id` is the diary space itself, where a new document is an entry.
pub(crate) async fn is_diary(conn: &mut SqliteConnection, id: i64) -> AppResult<bool> {
let role: Option<Option<String>> = sqlx::query_scalar("SELECT role FROM nodes WHERE id = ?1")
.bind(id)
.fetch_optional(&mut *conn)
.await?;
Ok(role.flatten().as_deref() == Some(crate::routes::nodes::DIARY))
}
#[cfg(test)]
mod tests {
use axum::{extract::State, Json};
use chrono::TimeZone;
use chrono_tz::Europe::Berlin;
use sqlx::SqlitePool;
use super::{around, 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 {
Berlin
.with_ymd_and_hms(y, mo, d, h, mi, 0)
.earliest()
.unwrap()
.timestamp()
}
async fn diary(db: &SqlitePool) -> i64 {
sqlx::query_scalar("SELECT id FROM nodes WHERE role = 'diary'")
.fetch_one(db)
.await
.unwrap()
}
async fn add(db: &SqlitePool, parent: i64, kind: &str, name: &str, body: &str, at: i64) -> i64 {
let slug = crate::routes::nodes::slugify(name);
let slug = if slug == "untitled" {
format!("e{at}")
} else {
slug
};
sqlx::query_scalar(
"INSERT INTO nodes (parent_id, kind, name, slug, body, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6) RETURNING id",
)
.bind(parent)
.bind(kind)
.bind(name)
.bind(slug)
.bind(body)
.bind(at)
.fetch_one(db)
.await
.unwrap()
}
async fn create(state: &AppState, input: CreateInput) -> String {
let Json(node) = crate::routes::nodes::create(
Session { id: "test".into() },
State(state.clone()),
Json(input),
)
.await
.unwrap();
node.body
}
fn input(parent_id: i64, name: &str, created_at: i64, template_id: Option<i64>) -> CreateInput {
CreateInput {
parent_id,
kind: None,
name: name.into(),
body: String::new(),
created_at: Some(created_at),
template_id,
}
}
#[test]
fn placeholders_keep_to_the_wall_clock_across_daylight_saving() {
let text = "{{weekday}} {{date}} {{time}} {{title}}";
// Spring: 01:59 is followed by 03:00.
assert_eq!(
fill(text, Berlin, at(2026, 3, 29, 1, 30), "a"),
"Sunday 2026-03-29 01:30 a"
);
assert_eq!(
fill(text, Berlin, at(2026, 3, 29, 1, 30) + 3600, "a"),
"Sunday 2026-03-29 03:30 a"
);
// Autumn: 02:30 happens twice, an hour apart.
let first = at(2026, 10, 25, 2, 30);
assert_eq!(fill("{{time}}", Berlin, first, ""), "02:30");
assert_eq!(fill("{{time}}", Berlin, first + 3600, ""), "02:30");
// Before midnight in UTC, after it here.
assert_eq!(
fill(text, Berlin, at(2026, 10, 21, 0, 30), "{{date}}"),
"Wednesday 2026-10-21 00:30 {{date}}"
);
}
#[tokio::test]
async fn a_new_document_starts_from_its_template_and_an_entry_from_the_day() {
let state = crate::routes::tests::state_in(".".into()).await;
let db = &state.db;
let diary = diary(db).await;
let work: i64 = sqlx::query_scalar("SELECT id FROM nodes WHERE slug = 'work'")
.fetch_one(db)
.await
.unwrap();
let morning = at(2026, 10, 20, 8, 15);
// No day template yet: an entry starts empty, as it always has.
assert_eq!(create(&state, input(diary, "", morning, None)).await, "");
let folder = add(db, work, "folder", "templates", "", 0).await;
let meeting = add(
db,
folder,
"document",
"meeting",
"# {{title}}\n{{weekday}} {{date}}\n",
0,
)
.await;
let body = create(&state, input(work, "Budget", morning, Some(meeting))).await;
assert_eq!(body, "# Budget\nTuesday 2026-10-20\n");
let folder = add(db, diary, "folder", "templates", "", morning).await;
add(db, folder, "document", "day", "## {{weekday}}\n", morning).await;
let body = create(&state, input(diary, "", at(2026, 10, 21, 9, 0), None)).await;
assert_eq!(body, "## Wednesday\n");
// The template is not an entry of the day it was written on.
let days = month_entries(db, Berlin, "2026-10").await.unwrap();
assert_eq!(days.len(), 2, "{days:?}");
// Capture that starts a day starts it from the template too.
let captured =
crate::routes::capture::tests::diary_at(db, "rain", at(2026, 10, 22, 7, 0)).await;
let body: String = sqlx::query_scalar("SELECT body FROM nodes WHERE id = ?1")
.bind(captured)
.fetch_one(db)
.await
.unwrap();
assert_eq!(body, "## Thursday\n\n- 07:00 rain\n");
}
#[tokio::test]
async fn a_month_holds_the_days_of_the_timezone() {
let state = crate::routes::tests::state_in(".".into()).await;
let db = &state.db;
let diary = diary(db).await;
// An hour apart in UTC, on 31 October both, but a month apart here.
let october = add(db, diary, "document", "", "", at(2026, 10, 31, 23, 30)).await;
let november = add(db, diary, "document", "", "", at(2026, 11, 1, 0, 30)).await;
let days = month_entries(db, Berlin, "2026-10").await.unwrap();
assert_eq!(days.iter().map(|e| e.id).collect::<Vec<_>>(), [october]);
assert_eq!(days[0].day, "2026-10-31");
let days = month_entries(db, Berlin, "2026-11").await.unwrap();
assert_eq!(days.iter().map(|e| e.id).collect::<Vec<_>>(), [november]);
assert_eq!(days[0].day, "2026-11-01");
assert!(days[0].path.starts_with("diary/"), "{}", days[0].path);
// December rolls over into the next year.
assert!(month_entries(db, Berlin, "2026-12")
.await
.unwrap()
.is_empty());
for month in ["2026-13", "october", ""] {
assert!(month_entries(db, Berlin, month).await.is_err(), "{month}");
}
}
#[tokio::test]
async fn the_entries_either_side_go_by_date() {
let state = crate::routes::tests::state_in(".".into()).await;
let db = &state.db;
let diary = diary(db).await;
let folder = add(db, diary, "folder", "2025", "", 0).await;
let older = add(db, folder, "document", "", "", at(2025, 12, 31, 12, 0)).await;
let middle = add(db, diary, "document", "", "", at(2026, 1, 2, 12, 0)).await;
// Written later, about an earlier day.
let backdated = add(db, diary, "document", "", "", at(2026, 1, 1, 12, 0)).await;
let found = around(db, Berlin, backdated).await.unwrap();
assert_eq!(found.prev.map(|e| e.id), Some(older));
assert_eq!(found.next.map(|e| e.id), Some(middle));
let found = around(db, Berlin, older).await.unwrap();
assert!(found.prev.is_none());
assert!(around(db, Berlin, middle).await.unwrap().next.is_none());
assert!(around(db, Berlin, folder).await.is_err());
}
#[tokio::test]
async fn on_this_day_remembers_the_leap_day_in_the_years_without_one() {
let state = crate::routes::tests::state_in(".".into()).await;
let db = &state.db;
let diary = diary(db).await;
let leap = add(db, diary, "document", "", "", at(2024, 2, 29, 20, 0)).await;
let plain = add(db, diary, "document", "", "", at(2025, 2, 28, 20, 0)).await;
add(db, diary, "document", "", "", at(2025, 3, 1, 20, 0)).await;
let today = add(db, diary, "document", "", "", at(2027, 2, 28, 8, 0)).await;
let ids = |found: Vec<super::Entry>| found.into_iter().map(|e| e.id).collect::<Vec<_>>();
// 2027 has no 29th, so its 28th remembers both, the latest year first;
// its own entry is today's, not a memory.
assert_eq!(
ids(on_this_day(db, Berlin, at(2027, 2, 28, 9, 0))
.await
.unwrap()),
[plain, leap]
);
// A leap year has a 29th of its own to remember the leap day on.
assert_eq!(
ids(on_this_day(db, Berlin, at(2028, 2, 28, 9, 0))
.await
.unwrap()),
[today, plain]
);
assert_eq!(
ids(on_this_day(db, Berlin, at(2028, 2, 29, 9, 0))
.await
.unwrap()),
[leap]
);
}
}
+4
View File
@@ -1,6 +1,7 @@
pub mod backup;
pub mod boards;
pub mod capture;
pub mod diary;
pub mod export;
pub mod import;
pub mod inspect;
@@ -56,6 +57,8 @@ pub fn api_router(max_upload: usize) -> Router<AppState> {
.route("/spaces", get(nodes::spaces).post(nodes::create_space))
.route("/spaces/overview", get(nodes::overview))
.route("/agenda", get(boards::agenda))
.route("/diary/days", get(diary::days))
.route("/diary/around/{id}", get(diary::around_entry))
.route("/capture", post(capture::capture))
.route("/spaces/{id}/board", get(boards::board))
.route("/spaces/{id}/lists", post(boards::create_list))
@@ -76,6 +79,7 @@ pub fn api_router(max_upload: usize) -> Router<AppState> {
.route("/nodes/{id}", delete(nodes::remove))
.route("/nodes/{id}/revisions", get(revisions::list))
.route("/nodes/{id}/backlinks", get(links::backlinks))
.route("/nodes/{id}/templates", get(diary::templates))
.route("/nodes/{id}/revisions/{rev}", get(revisions::get))
.route("/nodes/move", post(nodes::move_many))
.route("/nodes/{id}/move", post(nodes::move_node))
+36 -7
View File
@@ -10,7 +10,7 @@ use sqlx::{Row, SqliteConnection, SqlitePool};
use crate::{
auth::Session,
error::{AppError, AppResult},
routes::{links, media, revisions, trash},
routes::{diary, links, media, revisions, trash},
state::AppState,
};
@@ -148,6 +148,10 @@ pub struct CreateInput {
/// Optional override so a document can be backdated to the day it is about.
#[serde(default)]
pub created_at: Option<i64>,
/// A document to start from, its placeholders filled in for this one. A
/// new entry in the diary starts from `templates/day` without being asked.
#[serde(default)]
pub template_id: Option<i64>,
}
#[derive(Deserialize)]
@@ -208,7 +212,7 @@ const MOVE_AT_ONCE: usize = 1000;
/// How much of a body the list query reads. Comfortably more than an excerpt
/// needs, and a rounding error next to documents that run to thousands of words.
const HEAD_CHARS: usize = 1024;
pub(crate) const HEAD_CHARS: usize = 1024;
const EXCERPT_CHARS: usize = 180;
/// The columns every list query selects, so a row always converts the same way.
@@ -223,7 +227,7 @@ const SUMMARY_COLUMNS: &str =
/// A short, plain-ish preview of the body for the list pane. `head` is only the
/// leading `HEAD_CHARS` of the document, so truncation is inferred from its
/// length rather than measured against the whole body.
fn excerpt(head: &str) -> String {
pub(crate) fn excerpt(head: &str) -> String {
let flat: String = head
.lines()
.map(str::trim)
@@ -626,6 +630,31 @@ pub async fn create(
let created_at = input.created_at.unwrap_or(now);
let slug = unique_slug(&mut tx, Some(input.parent_id), &slugify(&input.name), None).await?;
let tz = state.config.mail.timezone;
let name = input.name.trim();
let body = match input.template_id {
Some(_) if kind != DOCUMENT => {
return Err(AppError::BadRequest(
"only a document starts from a template".into(),
));
}
Some(_) if !input.body.is_empty() => {
return Err(AppError::BadRequest(
"a document starts from a template or from a body, not both".into(),
));
}
Some(template) => diary::from_template(&mut tx, tz, template, created_at, name).await?,
None if kind == DOCUMENT
&& input.body.is_empty()
&& diary::is_diary(&mut tx, input.parent_id).await? =>
{
diary::day_template(&mut tx, tz, input.parent_id, created_at)
.await?
.unwrap_or_default()
}
None => input.body,
};
let id: i64 = sqlx::query(
"INSERT INTO nodes (parent_id, kind, name, slug, body, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
@@ -633,17 +662,17 @@ pub async fn create(
)
.bind(input.parent_id)
.bind(kind)
.bind(input.name.trim())
.bind(name)
.bind(&slug)
.bind(&input.body)
.bind(&body)
.bind(created_at)
.bind(now)
.fetch_one(&mut *tx)
.await?
.get("id");
media::link_to_node(&mut tx, id, &input.body).await?;
links::index(&mut tx, id, &input.body).await?;
media::link_to_node(&mut tx, id, &body).await?;
links::index(&mut tx, id, &body).await?;
// A `[[link]]` written before the document it names now finds it.
links::follow(&mut tx, id).await?;
tx.commit().await?;
+59 -4
View File
@@ -2,6 +2,7 @@
import { cycleTile, focusTile, followLink, hooks, runCommand } from './lib/commands'
import { embedSnippet } from './lib/markdown'
import Agenda from './lib/Agenda.svelte'
import Calendar from './lib/Calendar.svelte'
import Bar from './lib/Bar.svelte'
import Board from './lib/Board.svelte'
import CommandLine from './lib/CommandLine.svelte'
@@ -25,7 +26,7 @@
import Sidebar from './lib/Sidebar.svelte'
import StatusBar from './lib/StatusBar.svelte'
import { workspace } from './lib/store.svelte'
import { dwindle, neighbour, type Direction } from './lib/util'
import { dwindle, formatDay, neighbour, type Direction } from './lib/util'
/*
* A share link is /s/<token>#<key>. The fragment never leaves the browser, so
@@ -292,14 +293,18 @@
return false
}
/** The agenda's keys: j/k down the list, Enter to the card, Space finishes it. */
/**
* The agenda's keys: j/k down the list, Enter to the card, Space finishes it.
* Past the cards the cursor walks on into what was written on this day.
*/
function agendaKey(event: KeyboardEvent): boolean {
const cards = workspace.agendaCards
const card = cards[workspace.agendaCursor]
const past = workspace.agenda.on_this_day[workspace.agendaCursor - cards.length]
switch (event.key) {
case 'j':
case 'ArrowDown':
workspace.agendaCursor = Math.min(workspace.agendaCursor + 1, Math.max(cards.length - 1, 0))
workspace.agendaCursor = Math.min(workspace.agendaCursor + 1, Math.max(cards.length + workspace.agenda.on_this_day.length - 1, 0))
return true
case 'k':
case 'ArrowUp':
@@ -307,6 +312,10 @@
return true
case 'Enter':
if (card) void workspace.guard(() => workspace.goToCard(card))
else if (past) {
workspace.overlay = 'none'
void workspace.guard(() => workspace.openPath(past.path))
}
return true
case ' ':
if (card) void workspace.guard(() => workspace.finishCard(card))
@@ -335,6 +344,35 @@
}
}
/** The calendar's keys: h/l a day, j/k a week, H/L a month, Enter to the day. */
function calendarKey(event: KeyboardEvent): boolean {
const step: Record<string, [number, number]> = {
h: [-1, 0], ArrowLeft: [-1, 0], l: [1, 0], ArrowRight: [1, 0],
k: [-7, 0], ArrowUp: [-7, 0], j: [7, 0], ArrowDown: [7, 0],
H: [0, -1], L: [0, 1],
}
if (event.key in step) {
const [days, months] = step[event.key]
void workspace.guard(() => workspace.moveCalendar(days, months))
return true
}
switch (event.key) {
case '.':
workspace.calendarDay = formatDay(Math.floor(Date.now() / 1000))
void workspace.guard(() => workspace.loadCalendar())
return true
case 'Enter':
void workspace.guard(() => workspace.openCalendarDay()).then(() => hooks.focusEditor())
return true
case 'C':
case 'q':
workspace.overlay = 'none'
return true
default:
return false
}
}
/** `gO`'s list: j/k down the headings, Enter to the one picked. */
function outlineKey(event: KeyboardEvent): boolean {
const last = Math.max(workspace.outline.length - 1, 0)
@@ -675,6 +713,12 @@
return
}
}
if (workspace.overlay === 'calendar' && listening && !event.ctrlKey && !event.metaKey && !event.altKey) {
if (calendarKey(event)) {
event.preventDefault()
return
}
}
if (workspace.overlay === 'outline' && listening && !event.ctrlKey && !event.metaKey && !event.altKey) {
if (outlineKey(event)) {
event.preventDefault()
@@ -769,6 +813,11 @@
void runCommand('today')
return
}
if (key === 'C') {
event.preventDefault()
void runCommand('cal')
return
}
if (workspace.view === 'board') {
if (previous === 'g' && key === 'g') {
@@ -847,6 +896,12 @@
if (!reading || !hooks.scrollWide(amount)) workspace.say('nothing wider than the column in view', 'error')
return
}
// `[d` / `]d`: the diary entry of the day before or after, by its date.
if ((previous === '[' || previous === ']') && key === 'd') {
event.preventDefault()
void workspace.guard(() => workspace.stepEntry(previous === '[' ? -1 : 1))
return
}
if ((previous === ']' && key === ']') || (previous === '[' && key === '[')) {
event.preventDefault()
// Past the last heading there is still the end, as in vim.
@@ -1112,7 +1167,7 @@
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions, a11y_no_noninteractive_tabindex -->
<div class="overlay" tabindex="-1" bind:this={overlayEl} onclick={(e) => e.target === e.currentTarget && (workspace.overlay = 'none')}
>
{#if workspace.overlay === 'help'}<Help />{:else if workspace.overlay === 'overview'}<Overview />{:else if workspace.overlay === 'agenda'}<Agenda />{:else if workspace.overlay === 'actions'}<Actions />{:else if workspace.overlay === 'verify'}<Verify />{:else if workspace.overlay === 'outline'}<Headings />{:else if workspace.overlay === 'trash'}<Trash />{:else if workspace.overlay === 'history'}<History />{:else if workspace.overlay === 'backlinks'}<Backlinks />{:else}<MediaBrowser />{/if}
{#if workspace.overlay === 'help'}<Help />{:else if workspace.overlay === 'overview'}<Overview />{:else if workspace.overlay === 'agenda'}<Agenda />{:else if workspace.overlay === 'actions'}<Actions />{:else if workspace.overlay === 'verify'}<Verify />{:else if workspace.overlay === 'outline'}<Headings />{:else if workspace.overlay === 'trash'}<Trash />{:else if workspace.overlay === 'history'}<History />{:else if workspace.overlay === 'backlinks'}<Backlinks />{:else if workspace.overlay === 'calendar'}<Calendar />{:else}<MediaBrowser />{/if}
</div>
{/if}
</div>
+31 -2
View File
@@ -5,7 +5,7 @@
* had today yet. The keys live in App.svelte with the others; this draws
* and lets a pointer do the same.
*/
import type { AgendaCard } from './api'
import type { AgendaCard, DiaryEntry } from './api'
import { workspace } from './store.svelte'
import { runCommand } from './commands'
import Icon from './Icon.svelte'
@@ -23,6 +23,12 @@
sheet?.querySelector<HTMLElement>(`[data-index="${index}"]`)?.scrollIntoView({ block: 'nearest' })
})
/** An entry from an earlier year, in place in the diary. */
function open(entry: DiaryEntry) {
workspace.overlay = 'none'
void workspace.guard(() => workspace.openPath(entry.path))
}
/** Where a card sits in the walked order, across the groups. */
function indexOf(card: AgendaCard) {
return workspace.agendaCards.findIndex((c) => c.id === card.id)
@@ -80,10 +86,33 @@
<p class="clear">nothing late, nothing due this week, nothing in hand. <span class="kbd">c</span> captures the next thing.</p>
{/each}
{#if workspace.agenda.on_this_day.length}
<section class="past">
<h2>on this day <span class="n">{workspace.agenda.on_this_day.length}</span></h2>
<ul>
{#each workspace.agenda.on_this_day as entry, at (entry.id)}
{@const index = total + at}
<li>
<button
class="row"
class:at={index === workspace.agendaCursor}
data-index={index}
onclick={() => open(entry)}
onpointerenter={() => (workspace.agendaCursor = index)}
>
<span class="text">{entry.name.trim() || entry.excerpt || 'untitled'}</span>
<span class="due">{entry.day.slice(0, 4)}</span>
</button>
</li>
{/each}
</ul>
</section>
{/if}
<footer class="label">
<span class="kbd">j</span><span class="kbd">k</span> move
<span class="gap"></span>
<span class="kbd">↵</span> to its board
<span class="kbd">↵</span> open
<span class="gap"></span>
<span class="kbd">␣</span> done
<span class="gap"></span>
+171
View File
@@ -0,0 +1,171 @@
<script lang="ts">
/*
* `C`: the diary by month, the way a paper one is leafed through — the days
* written in marked, the day under the cursor shown below. The keys live in
* App.svelte with the others; this draws and lets a pointer do the same.
*/
import { workspace } from './store.svelte'
import { formatDay } from './util'
const WEEKDAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']
const month = $derived(workspace.calendarDay.slice(0, 7))
const title = $derived.by(() => {
const [y, m] = month.split('-').map(Number)
return new Date(y, m - 1, 1).toLocaleDateString(undefined, { month: 'long', year: 'numeric' }).toLowerCase()
})
/** The month's days in weeks from Monday, with blanks before the first. */
const cells = $derived.by(() => {
const [y, m] = month.split('-').map(Number)
const lead = (new Date(y, m - 1, 1).getDay() + 6) % 7
const length = new Date(y, m, 0).getDate()
const days: (string | null)[] = Array(lead).fill(null)
for (let d = 1; d <= length; d++) days.push(`${month}-${String(d).padStart(2, '0')}`)
return days
})
const today = $derived(formatDay(Math.floor(Date.now() / 1000)))
const counts = $derived(
workspace.calendarEntries.reduce((all, entry) => all.set(entry.day, (all.get(entry.day) ?? 0) + 1), new Map<string, number>()),
)
const chosen = $derived(workspace.calendarEntries.filter((entry) => entry.day === workspace.calendarDay))
function pick(day: string) {
workspace.calendarDay = day
void workspace.guard(() => workspace.openCalendarDay())
}
</script>
<div class="sheet">
<header>
<span class="label rule"><span class="marker"></span><span class="title">{title}</span> {workspace.calendarEntries.length} entries</span>
<button onclick={() => workspace.guard(() => workspace.moveCalendar(0, -1))} aria-label="previous month">‹</button>
<button onclick={() => workspace.guard(() => workspace.moveCalendar(0, 1))} aria-label="next month">›</button>
<button onclick={() => (workspace.overlay = 'none')}>esc</button>
</header>
<ol class="grid">
{#each WEEKDAYS as weekday (weekday)}
<li class="weekday label">{weekday}</li>
{/each}
{#each cells as day, index (day ?? `blank-${index}`)}
{#if day}
<li>
<button
class="day"
class:at={day === workspace.calendarDay}
class:today={day === today}
class:written={counts.has(day)}
onclick={() => pick(day)}
>
<span class="n">{Number(day.slice(8))}</span>
{#if (counts.get(day) ?? 0) > 1}<span class="many">{counts.get(day)}</span>{:else if counts.has(day)}<span class="dot"></span>{/if}
</button>
</li>
{:else}
<li></li>
{/if}
{/each}
</ol>
<section class="chosen">
<h2>{workspace.calendarDay}</h2>
{#each chosen as entry (entry.id)}
<p class="entry"><span class="name">{entry.name.trim() || entry.excerpt || 'untitled'}</span></p>
{:else}
<p class="none">nothing written — <span class="kbd">↵</span> starts it</p>
{/each}
</section>
<footer class="label">
<span class="kbd">h</span><span class="kbd">l</span> day
<span class="gap"></span>
<span class="kbd">j</span><span class="kbd">k</span> week
<span class="gap"></span>
<span class="kbd">H</span><span class="kbd">L</span> month
<span class="gap"></span>
<span class="kbd">.</span> today
<span class="gap"></span>
<span class="kbd">↵</span> open
<span class="gap"></span>
<span class="kbd">C</span><span class="kbd">esc</span> close
</footer>
</div>
<style>
.sheet {
display: flex;
flex-direction: column;
gap: 16px;
width: min(100%, 60ch);
max-height: 100%;
overflow-y: auto;
background: color-mix(in oklab, var(--hull) 97%, transparent);
border: 1px solid var(--surface-alt);
border-radius: var(--frame-radius);
padding: 20px 24px;
}
header { display: flex; align-items: center; gap: 12px; }
header .label { flex: 1; }
header .marker { background: var(--light); }
header .title { color: var(--light); }
.grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
list-style: none;
margin: 0;
padding: 0;
}
.weekday { text-align: center; font-size: 10px; color: var(--muted); }
.day {
position: relative;
display: grid;
place-items: center;
width: 100%;
aspect-ratio: 1.4;
padding: 0;
border: 1px solid transparent;
border-radius: var(--radius-s);
font-size: 13px;
letter-spacing: 0;
color: var(--subtle);
font-variant-numeric: tabular-nums;
}
.day:active { transform: none; }
.day.written { color: var(--fg); }
.day.today { border-color: var(--rim); }
.day.at { background: var(--light); color: var(--ink); }
.dot, .many { position: absolute; bottom: 4px; }
.dot { width: 5px; height: 5px; border-radius: 999px; background: var(--light); }
.many { font-size: 9px; color: var(--light); }
.day.at :is(.dot) { background: var(--ink); }
.day.at .many { color: var(--ink); }
.chosen h2 {
display: flex;
align-items: center;
gap: 10px;
margin: 0 0 6px;
font-size: 10px;
font-weight: 400;
letter-spacing: 0.22em;
color: var(--muted);
}
.chosen h2::after { content: ''; height: 1px; flex: 1; background: var(--overlay); }
.entry, .none { margin: 2px 0; font-size: 13px; }
.entry .name { font-family: var(--font-sans); font-size: 14px; color: var(--fg); }
.none { color: var(--subtle); }
footer { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; font-size: 10px; }
footer .kbd { margin-right: 2px; }
footer .gap { width: 14px; }
@media (max-width: 767px), (pointer: coarse) {
footer { display: none; }
}
</style>
+5 -3
View File
@@ -32,7 +32,9 @@
['M', 'pick up the whole folder — again puts it all back'],
['Ctrl / Shift-click', 'pick a row up · every row up to it — drag them onto a folder'],
['t', 'agenda — late, due this week, in hand, on every board'],
['T', "today's diary entry, started if there is none"],
['T', "today's diary entry, started if there is none — from templates/day when the diary has one"],
['C', 'the diary by month: 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'],
['b', "open this space's board"],
['c', "capture a card into this space's inbox, without leaving here"],
@@ -73,8 +75,8 @@
]
const agendaKeys: [string, string][] = [
['j / k', 'down / up the cards'],
['Enter', 'to the card, on its own board'],
['j / k', 'down / up the cards, then what was written on this day in earlier years'],
['Enter', 'to the card, on its own board — or to the entry'],
['Space', 'done, where it stands'],
['D', 'due date: fri, tm, +3d … — :due on the line'],
['H / L', 'to the list left / right on its board'],
+33
View File
@@ -183,6 +183,27 @@ export interface Agenda {
cards: AgendaCard[]
/** Whether the diary has an entry today; null when there is no diary. */
diary_today: boolean | null
/** Diary entries from this day in earlier years, the latest year first. */
on_this_day: DiaryEntry[]
}
/** A diary entry, by the local day it is about. */
export interface DiaryEntry {
id: number
name: string
/** `2026-10-20`, in the server's timezone. */
day: string
created_at: number
/** `diary/untitled`, to open it by. */
path: string
excerpt: string
}
/** A document in a space's `templates` folder. */
export interface Template {
id: number
name: string
slug: string
}
export interface BoardList {
@@ -433,8 +454,20 @@ export const api = {
name?: string
body?: string
created_at?: number
/** Start from this document, its `{{date}}` and the rest filled in. */
template_id?: number
}) => request<WorkspaceNode>('/nodes', { method: 'POST', body: body(input) }),
/** The templates of the space `id` is in. */
templates: (id: number) => request<Template[]>(`/nodes/${id}/templates`),
/** A month's diary entries: `2026-10`. */
diaryDays: (month: string) => request<DiaryEntry[]>(`/diary/days?month=${encodeURIComponent(month)}`),
/** The entries before and after a diary entry, by date. */
diaryAround: (id: number) =>
request<{ prev: DiaryEntry | null; next: DiaryEntry | null }>(`/diary/around/${id}`),
/**
* `base_updated_at`: refused with 409 if the node was saved since then.
* `restore`: the text is a revision brought back, so what it replaces is
+45 -9
View File
@@ -156,6 +156,23 @@ 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 templates of the space last asked about, for `:new` to complete. */
let templateCache: { space: number; names: string[] } | null = null
/** Tab cannot wait for the server: the names come on the press after the first. */
function templateNames(): string[] {
const here = workspace.spaceId
if (here === null) return []
if (templateCache?.space !== here) {
templateCache = { space: here, names: [] }
void api.templates(here).then(
(templates) => (templateCache = { space: here, names: templates.map((t) => t.slug) }),
() => (templateCache = null),
)
}
return templateCache.names
}
/** `:encrypt` and `:decrypt`: a file, or everything under a folder. */
const sealTarget = (encrypt: boolean) =>
workspace.guard(async () => {
@@ -526,23 +543,42 @@ export const commands: CommandSpec[] = [
{
name: 'new',
aliases: ['n', 'o'],
args: '[yyyy-mm-dd]',
args: '[template] [yyyy-mm-dd] [name]',
group: 'documents',
help: 'start a new document here, optionally dated',
help: "start a new document here — from one of the space's templates, dated, named; in the diary from templates/day",
complete: (arg) => (arg.includes(' ') ? [] : templateNames()),
run: async (arg) => {
await workspace.guard(async () => {
await workspace.createDocument()
if (arg) {
const at = parseDay(arg)
if (at === null) return workspace.say(`not a date: ${arg}`, 'error')
workspace.draft.created_at = at
workspace.dirty = true
await workspace.save()
const words = arg.trim().split(/\s+/).filter(Boolean)
const here = workspace.spaceId
const templates = words.length && here !== null ? await api.templates(here) : []
if (words.length && here !== null) templateCache = { space: here, names: templates.map((t) => t.slug) }
const wanted = words[0]?.toLowerCase()
const template = templates.find((t) => t.slug === wanted || t.name.toLowerCase() === wanted)
if (template) words.shift()
let created_at: number | undefined
if (words.length && /^\d{4}-\d{2}-\d{2}$/.test(words[0])) {
const at = parseDay(words[0])
if (at === null) return workspace.say(`not a date: ${words[0]}`, 'error')
created_at = at
words.shift()
}
await workspace.createDocument(words.join(' '), { created_at, template_id: template?.id })
})
hooks.focusEditor()
},
},
{
name: 'cal',
aliases: ['calendar'],
group: 'documents',
help: 'the diary by month — h/l a day, j/k a week, H/L a month, Enter opens the day or starts it',
run: () =>
workspace.guard(async () => {
if (workspace.overlay === 'calendar') return void (workspace.overlay = 'none')
await workspace.openCalendar()
}),
},
{
name: 'agenda',
aliases: ['ag'],
+62 -7
View File
@@ -9,6 +9,7 @@ import {
type Board,
type Card,
type Crumb,
type DiaryEntry,
type WorkspaceNode,
type MediaFile,
type NodeSummary,
@@ -18,7 +19,7 @@ import {
type TrashItem,
type VaultStatus,
} from './api'
import { formatBytes, formatStamp, isTouchDevice, sealedNote, type Heading } from './util'
import { formatBytes, formatDay, formatStamp, isTouchDevice, parseDay, sealedNote, type Heading } from './util'
export type Pane = 'list' | 'document'
/** The tree of documents, or the board of the space being browsed. */
@@ -282,7 +283,7 @@ class Workspace {
pane = $state<Pane>('list')
flash = $state<Flash>(null)
overlay = $state<'none' | 'help' | 'media' | 'overview' | 'agenda' | 'actions' | 'verify' | 'outline' | 'trash' | 'history' | 'backlinks'>('none')
overlay = $state<'none' | 'help' | 'media' | 'overview' | 'agenda' | 'actions' | 'verify' | 'outline' | 'trash' | 'history' | 'backlinks' | 'calendar'>('none')
/** `gO`: the headings of what is on screen, to jump between from the keyboard. */
outline = $state.raw<Heading[]>([])
outlineCursor = $state(0)
@@ -291,9 +292,13 @@ class Workspace {
* What is due or in hand on every board, kept loaded all the time: the bar
* counts from it, so what is late is in sight without going to look.
*/
agenda = $state<Agenda>({ cards: [], diary_today: null })
agenda = $state<Agenda>({ cards: [], diary_today: null, on_this_day: [] })
agendaCursor = $state(0)
/** `C`: the day the calendar's cursor is on, `2026-10-20`, and its month's entries. */
calendarDay = $state('')
calendarEntries = $state.raw<DiaryEntry[]>([])
/** A search across every space rather than the one being browsed — Ctrl-P. */
globalSearch = $state(false)
@@ -939,11 +944,15 @@ class Workspace {
else await this.openNode(selected.id, edit)
}
/** A new document is created immediately so uploads have something to attach to. */
async createDocument(name = '') {
/**
* A new document is created immediately so uploads have something to attach
* to. In the diary it starts from `templates/day` when there is one; the
* server sees to that, so capture does the same.
*/
async createDocument(name = '', from: { created_at?: number; template_id?: number } = {}) {
const parent = this.parentId
if (parent === null) return
const node = await api.createNode({ parent_id: parent, name, body: '' })
const node = await api.createNode({ parent_id: parent, name, body: '', ...from })
this.query = ''
this.globalSearch = false
await this.refresh()
@@ -1285,7 +1294,7 @@ class Workspace {
} catch {
/* the counter is a convenience; a failed refresh leaves the last one up */
}
this.agendaCursor = Math.min(this.agendaCursor, Math.max(this.agendaCards.length - 1, 0))
this.agendaCursor = Math.min(this.agendaCursor, Math.max(this.agendaCards.length + this.agenda.on_this_day.length - 1, 0))
}
/**
@@ -1302,6 +1311,52 @@ class Workspace {
this.overlay = 'agenda'
}
/** `C`: the calendar, on the day of the entry being read or else today. */
async openCalendar() {
const open = this.open
const reading = open && this.spaces.find((space) => space.id === open.path[0]?.id)?.role === 'diary'
this.calendarDay = formatDay(reading ? open.created_at : Math.floor(Date.now() / 1000))
await this.loadCalendar()
this.overlay = 'calendar'
}
async loadCalendar() {
this.calendarEntries = await api.diaryDays(this.calendarDay.slice(0, 7))
}
/** The calendar's cursor, `days` along or `months` over; a month keeps the day where it can. */
async moveCalendar(days: number, months = 0) {
const [y, m, d] = this.calendarDay.split('-').map(Number)
const last = new Date(y, m - 1 + months + 1, 0).getDate()
const date = new Date(y, m - 1 + months, Math.min(d, last) + days, 12)
const before = this.calendarDay.slice(0, 7)
this.calendarDay = formatDay(Math.floor(date.getTime() / 1000))
if (this.calendarDay.slice(0, 7) !== before) await this.loadCalendar()
}
/** Enter in the calendar: that day's entry, or a new one dated that day. */
async openCalendarDay() {
const day = this.calendarDay
const entry = this.calendarEntries.find((e) => e.day === day)
this.overlay = 'none'
if (entry) return this.openPath(entry.path)
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 })
}
/** `[d` / `]d`: the diary entry before or after the one being read. */
async stepEntry(by: -1 | 1) {
const open = this.open
if (!open) return this.say('nothing open', 'error')
const { prev, next } = await api.diaryAround(open.id)
const entry = by < 0 ? prev : next
if (!entry) return this.say(by < 0 ? 'no entry before this one' : 'no entry after this one')
await this.openPath(entry.path)
this.say(entry.day)
}
/** Enter in the agenda: to the card, on its own board. */
async goToCard(card: AgendaCard) {
this.overlay = 'none'