mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-04 00:47:01 +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
+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?;
|
||||
|
||||
Reference in new issue
Block a user