mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
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:
1 parent
f629f5af97
commit
824f2bb945
14 files changed
+1048
-50
No files matched your search
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)")
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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'],
|
||||
|
||||
@@ -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
@@ -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'],
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in new issue
Block a user