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