From a42f9074a2427c7af3eed450908fe0a1bd8a833e Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Tue, 20 Oct 2026 20:20:40 +0200 Subject: [PATCH] links: a rename or a move no longer breaks them A document links to another by its path, `[the plan](/n/work/plan)`, or now by its title, `[[Plan]]` or `[[Plan|the plan]]`. What each document links to is kept in a new `links` table, rebuilt whenever its text is written: from the browser, over the mount, or by an import. A title is looked for in the linking document's space first, then in every other. Renaming or moving a document, or a folder above it, rewrites the links to it in every document that has one, in the same transaction: the path to the new one, a title to the new name, with the label left as it was. Each rewritten document keeps a revision of what it said before. A link to something in the trash keeps its row, so it follows the thing when it is restored somewhere else. A document created, or renamed, under a title that a link was waiting for is found by that link from then on. The reader draws `[[titles]]` as links, and a title no document has yet as a dimmed one that offers to write that document. `gf` follows either from the editor, and `[[` there completes titles from the search. `gb` and `:backlinks` list the documents that link to the open one, with the line each does it on; the line under the title counts them. A share page shows these links as plain text, as it does every /n/ link. The documents written before this are indexed once, when the server starts and finds the table empty. --- README.md | 30 +- migrations/0021_links.sql | 17 + src/main.rs | 7 + src/routes/import.rs | 3 +- src/routes/links.rs | 797 ++++++++++++++++++++++++++++++++ src/routes/mod.rs | 2 + src/routes/nodes.rs | 52 ++- src/routes/trash.rs | 2 + src/sftp/fs.rs | 61 ++- web/bun.lock | 1 + web/package.json | 1 + web/src/App.svelte | 39 +- web/src/app.css | 2 + web/src/lib/Backlinks.svelte | 101 ++++ web/src/lib/DocumentPane.svelte | 9 +- web/src/lib/Editor.svelte | 36 ++ web/src/lib/Help.svelte | 2 + web/src/lib/Shared.svelte | 2 +- web/src/lib/api.ts | 16 + web/src/lib/commands.ts | 28 +- web/src/lib/markdown.ts | 42 +- web/src/lib/store.svelte.ts | 49 +- web/src/lib/util.ts | 17 +- 23 files changed, 1281 insertions(+), 35 deletions(-) create mode 100644 migrations/0021_links.sql create mode 100644 src/routes/links.rs create mode 100644 web/src/lib/Backlinks.svelte diff --git a/README.md b/README.md index 74658c9..8691ed6 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,7 @@ it; in the document pane it is that document. In the browse pane: | `gO` | the document's headings as a list — `j`/`k`, `Enter` jumps there (`:outline`) | | `gx` | open the file under the cursor in a tab of its own | | `gh` | the open document's earlier versions, as diffs — `j`/`k`, `Enter` brings one back (`:history`) | +| `gb` | the documents that link to the open one — `j`/`k`, `Enter` opens one (`:backlinks`) | | `F` | a letter over every link and button in view; typing it follows it — links out of the workspace open in a tab of their own | | `Enter`, `l` | open the highlighted entry; on an open keystore or sealed file, `Enter` asks for its password | | `o` | a new document here, straight into insert mode | @@ -117,7 +118,8 @@ mode, macros, `:w` to write. `:help` lists every ex command; the useful ones are `: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 headings as they do in the reader. `gf` follows the link under the cursor, saving -first, and `gx` opens it in a new tab. The rest: `:today` opens today's entry, `:edit [id]` (or `:12`) +first, and `gx` opens it in a new tab. `[[` in insert mode offers the titles of +documents to link to. The rest: `:today` opens today's entry, `:edit [id]` (or `:12`) opens a node by id, `:next`, `:prev`, `:first`, `:last` and `:random` walk the list (`:bn` and `:bp` walk the tiles), `:delete [id]` deletes, `:copy` puts the whole document on the clipboard, `:clear` drops the search, `:trash` lists what was deleted, `:reload` re-reads the folder, `:stats` counts words @@ -313,6 +315,31 @@ the tree is never one of them: it is deleted from its folder, not from highlighted one at the cursor while writing, `y` copies its address and `x` deletes it, asking first. +## Links + +A document links to another by its address, `[the plan](/n/work/notes/plan)`, +or by its title, `[[Plan]]` or `[[Plan|the plan]]`. A title is looked for in +the space the link is written in first, then in every other; it is a name in +any case, or the slug that name has. `[[` in the editor offers the titles as +the search finds them. A title no document has yet is drawn dimmed, and +following it — a click, `F`, `gf` — offers to write that document in the +folder being browsed. + +Links keep working when what they link to moves. Renaming a document, or a +folder above it, or moving either somewhere else — in the browser or over the +mount — rewrites the links to it in every document that has one, in the same +write: the path to the new one, a `[[title]]` to the new name, the label left +as it was. A rewritten document keeps a version of what it said before, like +any other save. A link to something in the trash stays as it is and follows +it when it is restored; once it is deleted for good the text of the link +stays, and leads nowhere. + +`gb` or `:backlinks` lists the documents that link to the open one, each with +the line it does so on, and the line under the title says how many there are. +A link inside a card, or one written into prose rather than as a markdown +link, is not counted or rewritten. The links of the documents written before +there were any are indexed once, when the server first starts with this. + ## Files A file is a node like a document, only with bytes instead of text: it sits in @@ -692,6 +719,7 @@ sealed or not. | `GET` | `/api/resolve?path=` | the node behind a slug path, `work/notes/monday` | | `GET` | `/api/nodes/{id}/revisions` | a document's earlier versions, the newest first | | `GET` | `/api/nodes/{id}/revisions/{rev}` | one earlier version, with its text | +| `GET` | `/api/nodes/{id}/backlinks` | the documents that link to this one, with the line each does it on | | `POST`/`DELETE` | `/api/nodes/{id}/share` | mint or rotate / revoke a share link | | `GET` | `/api/export` | the whole workspace, boards and dates included, as a zip | | `GET`/`POST` | `/api/backup` | Proton Drive mirror: status / run now | diff --git a/migrations/0021_links.sql b/migrations/0021_links.sql new file mode 100644 index 0000000..32e23f4 --- /dev/null +++ b/migrations/0021_links.sql @@ -0,0 +1,17 @@ +-- The links between documents, as their text writes them: a `/n/...` path, +-- or a `[[title]]`. Rebuilt from a document's text whenever it is written, +-- and read the other way round: for what links to a document, and for the +-- texts to rewrite when what they link to is renamed or moved. +-- +-- Nothing fills it for the documents already written; the server indexes +-- them all when it starts and finds it empty (see `links::backfill`). +CREATE TABLE links ( + from_id INTEGER NOT NULL REFERENCES nodes (id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK (kind IN ('path', 'wiki')), + -- What the link says: the path after `/n/`, or the title in the brackets. + target TEXT NOT NULL, + to_id INTEGER NOT NULL REFERENCES nodes (id) ON DELETE CASCADE, + PRIMARY KEY (from_id, kind, target) +) WITHOUT ROWID; + +CREATE INDEX idx_links_to ON links (to_id); diff --git a/src/main.rs b/src/main.rs index 82df2a8..1b2b091 100644 --- a/src/main.rs +++ b/src/main.rs @@ -143,6 +143,13 @@ async fn main() -> Result<()> { sessions, }; routes::seal::spawn_rekey(state.clone()); + // Before the first request, so no document reads as linked from nowhere. + match routes::links::backfill(&state.db).await { + Ok(0) => {} + Ok(1) => tracing::info!("indexed the links of 1 document"), + Ok(linking) => tracing::info!("indexed the links of {linking} documents"), + Err(e) => tracing::warn!("could not index the links between documents: {e:?}"), + } if let Some(sftp_bind) = config.sftp.bind { let sftp_state = state.clone(); diff --git a/src/routes/import.rs b/src/routes/import.rs index 29bd200..3079ddb 100644 --- a/src/routes/import.rs +++ b/src/routes/import.rs @@ -23,7 +23,7 @@ use crate::{ auth::Session, error::{AppError, AppResult}, routes::{ - boards, + boards, links, manifest::{self, Manifest, NodeMeta}, media, nodes::{self, next_slug, slugify, DOCUMENT, FILE, FOLDER, SPACE}, @@ -968,6 +968,7 @@ async fn build( .await?; } media::link_to_node(&mut tx, *id, &rewritten).await?; + links::index(&mut tx, *id, &rewritten).await?; } // The boards come back only where spaces do. diff --git a/src/routes/links.rs b/src/routes/links.rs new file mode 100644 index 0000000..ca103bb --- /dev/null +++ b/src/routes/links.rs @@ -0,0 +1,797 @@ +//! Links between documents. A document links to another by the path the +//! address bar shows, `[the plan](/n/work/plan)`, or by its title, +//! `[[Plan]]` or `[[Plan|the plan]]`. +//! +//! What a document links to is kept in `links`, rebuilt whenever its text is +//! written. Read the other way round, that is what links to a document, and +//! the texts a rename or a move would break: those are rewritten, in the same +//! transaction, so a link keeps working however its target moves around. + +use std::{ + cmp::Reverse, + collections::{BTreeMap, BTreeSet, HashMap}, + sync::LazyLock, +}; + +use axum::{ + extract::{Path, State}, + Json, +}; +use regex::Regex; +use serde::Serialize; +use sqlx::{Row, SqliteConnection, SqlitePool}; + +use crate::{ + auth::Session, + error::{AppError, AppResult}, + routes::{nodes, revisions, trash}, + state::AppState, +}; + +/// A markdown link to a path in the workspace, inline or by reference: +/// `](/n/work/plan)` or `[plan]: /n/work/plan`. A path anywhere else in the +/// prose is text about one, not a link. +static PATH_LINK: LazyLock = LazyLock::new(|| { + Regex::new(r"(?m)(?:\]\(\s* = LazyLock::new(|| { + Regex::new(r"\[\[([^\[\]|\n]+)(\|[^\[\]\n]*)?\]\]").expect("static regex is valid") +}); + +/// How a link names what it links to. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +enum Kind { + /// By its path, `work/plan`. + Path, + /// By its title, `Plan`. + Wiki, +} + +impl Kind { + fn as_str(self) -> &'static str { + match self { + Kind::Path => "path", + Kind::Wiki => "wiki", + } + } + + fn parse(kind: &str) -> Self { + if kind == "wiki" { + Kind::Wiki + } else { + Kind::Path + } + } +} + +/// The path links in `body`, each as the span of the path after `/n/`. +fn paths(body: &str) -> impl Iterator> { + PATH_LINK.captures_iter(body).filter_map(|caps| { + let path = caps.get(1)?; + // The path has to end where the link does: `/n/work/Plan` or + // `/n/work/plan.md` is not a link to `work`. + let rest = &body[path.end()..]; + let rest = rest.strip_prefix('/').unwrap_or(rest); + let ends = rest + .chars() + .next() + .is_none_or(|c| c.is_whitespace() || matches!(c, ')' | '#' | '?' | '>' | '"' | '\'')); + ends.then_some(path) + }) +} + +/// What `body` links to, as it is written. +fn written(body: &str) -> BTreeSet<(Kind, String)> { + let mut found: BTreeSet<(Kind, String)> = paths(body) + .map(|path| (Kind::Path, path.as_str().to_string())) + .collect(); + for caps in WIKILINK.captures_iter(body) { + let title = caps[1].trim(); + if !title.is_empty() { + found.insert((Kind::Wiki, title.to_string())); + } + } + found +} + +/// `body` with the links named in `to` pointed elsewhere: each target written +/// there becomes the one it maps to. A label stays as it was. +fn rewrite(body: &str, to: &HashMap<(Kind, String), String>) -> String { + let mut out = String::with_capacity(body.len()); + let mut last = 0; + for path in paths(body) { + if let Some(new) = to.get(&(Kind::Path, path.as_str().to_string())) { + out.push_str(&body[last..path.start()]); + out.push_str(new); + last = path.end(); + } + } + out.push_str(&body[last..]); + + WIKILINK + .replace_all(&out, |caps: ®ex::Captures| { + match to.get(&(Kind::Wiki, caps[1].trim().to_string())) { + Some(new) => format!("[[{new}{}]]", caps.get(2).map_or("", |m| m.as_str())), + None => caps[0].to_string(), + } + }) + .into_owned() +} + +/// The node a slug path names, from a space down, as an `/n/` address +/// names it. Never one in the trash, which is no space. +pub async fn resolve_path(db: &mut SqliteConnection, path: &str) -> AppResult> { + let mut current: Option = None; + for segment in path.split('/').filter(|s| !s.is_empty()) { + let found: Option = sqlx::query_scalar( + "SELECT id FROM nodes + WHERE parent_id IS ?1 AND slug = ?2 AND (?1 IS NOT NULL OR kind = 'space')", + ) + .bind(current) + .bind(segment) + .fetch_optional(&mut *db) + .await?; + let Some(id) = found else { + return Ok(None); + }; + current = Some(id); + } + Ok(current) +} + +/// The space a node is in, or the node itself when it is one. `None` in the +/// trash. +async fn space_of(db: &mut SqliteConnection, id: i64) -> AppResult> { + Ok(sqlx::query_scalar( + "WITH RECURSIVE up (id, parent_id) AS ( + SELECT id, parent_id FROM nodes WHERE id = ?1 + UNION ALL + SELECT n.id, n.parent_id FROM nodes n JOIN up ON n.id = up.parent_id + ) + SELECT n.id FROM up JOIN nodes n ON n.id = up.id + WHERE up.parent_id IS NULL AND n.kind = 'space'", + ) + .bind(id) + .fetch_optional(&mut *db) + .await?) +} + +/// The document or file a `[[title]]` names: one called that, whatever the +/// case, or one whose slug it is. In the space it is written in first, then +/// in any; one called that before one only slugged so; the oldest of what is +/// still left. Never one in the trash. +async fn resolve_title( + db: &mut SqliteConnection, + title: &str, + space: Option, +) -> AppResult> { + // `[[???]]` slugs to `untitled`, which is not what it names. + let slug = title + .chars() + .any(char::is_alphanumeric) + .then(|| nodes::slugify(title)); + let rows = sqlx::query( + "WITH RECURSIVE up (origin, id, parent_id) AS ( + SELECT id, id, parent_id FROM nodes + WHERE kind IN ('document', 'file') AND (name = ?1 COLLATE NOCASE OR slug = ?2) + UNION ALL + SELECT up.origin, n.id, n.parent_id FROM nodes n JOIN up ON n.id = up.parent_id + ) + SELECT up.origin, up.id AS space, (o.name = ?1 COLLATE NOCASE) AS named + FROM up JOIN nodes s ON s.id = up.id JOIN nodes o ON o.id = up.origin + WHERE up.parent_id IS NULL AND s.kind = 'space'", + ) + .bind(title) + .bind(slug) + .fetch_all(&mut *db) + .await?; + + Ok(rows + .iter() + .map(|row| { + let origin: i64 = row.get("origin"); + let here = space.is_some_and(|space| row.get::("space") == space); + (here, row.get::("named"), Reverse(origin)) + }) + .max() + .map(|(_, _, Reverse(origin))| origin)) +} + +async fn resolve( + db: &mut SqliteConnection, + kind: Kind, + target: &str, + space: Option, +) -> AppResult> { + match kind { + Kind::Path => resolve_path(db, target).await, + Kind::Wiki => resolve_title(db, target, space).await, + } +} + +/// A node's path from its space, `work/notes/plan`, as `/n/` writes it. +async fn path_text(db: &mut SqliteConnection, id: i64) -> AppResult { + let slugs: Vec = sqlx::query_scalar( + "WITH RECURSIVE up (id, parent_id, slug, depth) AS ( + SELECT id, parent_id, slug, 0 FROM nodes WHERE id = ?1 + UNION ALL + SELECT n.id, n.parent_id, n.slug, up.depth + 1 + FROM nodes n JOIN up ON n.id = up.parent_id + ) + SELECT slug FROM up ORDER BY depth DESC", + ) + .bind(id) + .fetch_all(&mut *db) + .await?; + Ok(slugs.join("/")) +} + +/// What a `[[link]]` to a node says: its name, or its slug when the name +/// could not stand between the brackets. +async fn title_of(db: &mut SqliteConnection, id: i64) -> AppResult { + let row = sqlx::query("SELECT name, slug FROM nodes WHERE id = ?1") + .bind(id) + .fetch_one(&mut *db) + .await?; + let name: String = row.get("name"); + let name = name.trim(); + Ok(if name.is_empty() || name.contains(['[', ']', '|', '\n']) { + row.get("slug") + } else { + name.to_string() + }) +} + +/// Record what `from` links to. Called by every write of a document's text, +/// after it, in the same transaction. +pub async fn index(db: &mut SqliteConnection, from: i64, body: &str) -> AppResult<()> { + let space = space_of(db, from).await?; + let before: HashMap<(Kind, String), i64> = + sqlx::query("SELECT kind, target, to_id FROM links WHERE from_id = ?1") + .bind(from) + .fetch_all(&mut *db) + .await? + .iter() + .map(|row| { + ( + (Kind::parse(row.get("kind")), row.get("target")), + row.get("to_id"), + ) + }) + .collect(); + + let mut rows = Vec::new(); + for (kind, target) in written(body) { + let to = match resolve(db, kind, &target, space).await? { + Some(to) => Some(to), + // What it linked to is in the trash. The row stays, so a restore + // that puts it somewhere else still finds the link to rewrite. + None => match before.get(&(kind, target.clone())) { + Some(&to) if trash::in_trash(db, to).await? => Some(to), + _ => None, + }, + }; + if let Some(to) = to.filter(|&to| to != from) { + rows.push((kind, target, to)); + } + } + + sqlx::query("DELETE FROM links WHERE from_id = ?1") + .bind(from) + .execute(&mut *db) + .await?; + for (kind, target, to) in rows { + sqlx::query("INSERT INTO links (from_id, kind, target, to_id) VALUES (?1, ?2, ?3, ?4)") + .bind(from) + .bind(kind.as_str()) + .bind(&target) + .bind(to) + .execute(&mut *db) + .await?; + } + Ok(()) +} + +/// After `id` was created, renamed or moved, in the same transaction. Every +/// link to it, or to anything under it, that no longer finds what it linked +/// to is rewritten to find it again; a link that found nothing until now and +/// names it now is recorded. Returns how many documents were rewritten. +pub async fn follow(db: &mut SqliteConnection, id: i64) -> AppResult { + // Nothing in the trash is followed there: its links stay as they were + // written, for whatever the restore brings it back as. + if trash::in_trash(db, id).await? { + return Ok(0); + } + let rows = sqlx::query( + "WITH RECURSIVE sub (id) AS ( + SELECT ?1 + UNION ALL + SELECT n.id FROM nodes n JOIN sub ON n.parent_id = sub.id + ) + SELECT from_id, kind, target, to_id FROM links + WHERE to_id IN (SELECT id FROM sub)", + ) + .bind(id) + .fetch_all(&mut *db) + .await?; + + let mut linking: BTreeMap> = BTreeMap::new(); + for row in &rows { + linking.entry(row.get("from_id")).or_default().push(( + Kind::parse(row.get("kind")), + row.get("target"), + row.get("to_id"), + )); + } + + let mut rewritten = 0; + for (from, links) in linking { + let space = space_of(db, from).await?; + let mut to = HashMap::new(); + for (kind, target, node) in links { + if resolve(db, kind, &target, space).await? == Some(node) { + continue; + } + let now = match kind { + Kind::Path => path_text(db, node).await?, + Kind::Wiki => title_of(db, node).await?, + }; + to.insert((kind, target), now); + } + if to.is_empty() { + continue; + } + + let body: String = sqlx::query_scalar("SELECT body FROM nodes WHERE id = ?1") + .bind(from) + .fetch_one(&mut *db) + .await?; + let body_now = rewrite(&body, &to); + if body_now == body { + continue; + } + revisions::keep(db, from, &body_now, revisions::Reason::Edit).await?; + // `updated_at` moves, so an editor open on the old text is told it + // changed rather than saving the broken links back over it. + sqlx::query("UPDATE nodes SET body = ?1, updated_at = ?2 WHERE id = ?3") + .bind(&body_now) + .bind(crate::now()) + .bind(from) + .execute(&mut *db) + .await?; + index(db, from, &body_now).await?; + rewritten += 1; + } + + claim(db, id).await?; + Ok(rewritten) +} + +/// Index again the documents that may have been waiting for `id`: whose +/// `[[title]]` or path named nothing until it was called that, or put there. +/// A rough cut by text, as the index then decides what each link finds. +async fn claim(db: &mut SqliteConnection, id: i64) -> AppResult<()> { + let row = sqlx::query("SELECT name, slug FROM nodes WHERE id = ?1") + .bind(id) + .fetch_one(&mut *db) + .await?; + // `lower` in SQLite folds ASCII only, so the name is folded the same way. + let name = row.get::("name").trim().to_ascii_lowercase(); + let slug: String = row.get("slug"); + let path = path_text(db, id).await?; + let waiting: Vec<(i64, String)> = sqlx::query_as( + "SELECT id, body FROM nodes + WHERE kind = 'document' AND id <> ?1 + AND (instr(body, '/n/' || ?4) > 0 + OR (instr(body, '[[') > 0 + AND ((?2 <> '' AND instr(lower(body), ?2) > 0) OR instr(body, ?3) > 0)))", + ) + .bind(id) + .bind(&name) + .bind(&slug) + .bind(&path) + .fetch_all(&mut *db) + .await?; + for (from, body) in waiting { + index(db, from, &body).await?; + } + Ok(()) +} + +/// The documents that link to `id`, the last written first; none in the +/// trash. +async fn linking(db: &mut SqliteConnection, id: i64) -> AppResult> { + let from: Vec = sqlx::query_scalar( + "SELECT DISTINCT l.from_id FROM links l JOIN nodes n ON n.id = l.from_id + WHERE l.to_id = ?1 ORDER BY n.updated_at DESC, n.id", + ) + .bind(id) + .fetch_all(&mut *db) + .await?; + let mut live = Vec::with_capacity(from.len()); + for from in from { + if !trash::in_trash(db, from).await? { + live.push(from); + } + } + Ok(live) +} + +/// How many documents link to `id`, for the line under its title. +pub async fn linked_from(db: &mut SqliteConnection, id: i64) -> AppResult { + Ok(linking(db, id).await?.len() as i64) +} + +/// Where the `[[titles]]` in `body` lead, as `/n/` paths, for the reader to +/// draw them as links. A title that leads nowhere is left out. +pub async fn wikilinks( + db: &mut SqliteConnection, + from: i64, + body: &str, +) -> AppResult> { + let mut found = BTreeMap::new(); + let titles: Vec = written(body) + .into_iter() + .filter(|(kind, _)| *kind == Kind::Wiki) + .map(|(_, title)| title) + .collect(); + 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?); + } + } + Ok(found) +} + +#[derive(Serialize)] +pub struct Backlink { + pub id: i64, + pub name: String, + pub slug: String, + /// Where it sits, `work/notes/plan`. + pub path: String, + /// The line the link is on. + pub context: String, +} + +/// How much of the line a link is on a backlink shows. +const CONTEXT_CHARS: usize = 200; + +/// `GET /api/nodes/{id}/backlinks` — the documents that link here. +pub async fn backlinks( + _: Session, + State(state): State, + Path(id): Path, +) -> AppResult>> { + let mut conn = state.db.acquire().await?; + if trash::in_trash(&mut conn, id).await? { + return Err(AppError::NotFound); + } + let mut found = Vec::new(); + for from in linking(&mut conn, id).await? { + let row = sqlx::query("SELECT name, slug, body FROM nodes WHERE id = ?1") + .bind(from) + .fetch_one(&mut *conn) + .await?; + let spellings: Vec = + sqlx::query("SELECT kind, target FROM links WHERE from_id = ?1 AND to_id = ?2") + .bind(from) + .bind(id) + .fetch_all(&mut *conn) + .await? + .iter() + .map(|link| match Kind::parse(link.get("kind")) { + Kind::Path => format!("/n/{}", link.get::("target")), + Kind::Wiki => format!("[[{}", link.get::("target")), + }) + .collect(); + let body: String = row.get("body"); + let context = body + .lines() + .find(|line| { + spellings + .iter() + .any(|spelling| line.contains(spelling.as_str())) + }) + .unwrap_or_default() + .trim(); + let mut context: String = context.chars().take(CONTEXT_CHARS).collect(); + if context.chars().count() == CONTEXT_CHARS { + context.push('…'); + } + found.push(Backlink { + id: from, + name: row.get("name"), + slug: row.get("slug"), + path: path_text(&mut conn, from).await?, + context, + }); + } + Ok(Json(found)) +} + +/// Index every document, when there is no link recorded at all: the ones +/// written before links were kept. Returns how many documents link anywhere. +pub async fn backfill(db: &SqlitePool) -> AppResult { + let any: bool = sqlx::query_scalar("SELECT EXISTS (SELECT 1 FROM links)") + .fetch_one(db) + .await?; + if any { + return Ok(0); + } + let documents = sqlx::query(&format!( + "WITH RECURSIVE {} SELECT id, body FROM nodes + WHERE kind = 'document' AND id IN (SELECT id FROM live) + AND (instr(body, '/n/') > 0 OR instr(body, '[[') > 0)", + nodes::LIVE + )) + .fetch_all(db) + .await?; + + let mut tx = crate::db::begin_write(db).await?; + for row in &documents { + index(&mut tx, row.get("id"), row.get("body")).await?; + } + let linking: i64 = sqlx::query_scalar("SELECT COUNT(DISTINCT from_id) FROM links") + .fetch_one(&mut *tx) + .await?; + tx.commit().await?; + Ok(linking as usize) +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use axum::extract::{Path, State}; + + use super::{rewrite, written, Kind}; + use crate::{routes::nodes, state::AppState}; + + #[test] + fn what_counts_as_a_link() { + let body = "[plan](/n/work/plan) and [notes]()\n\ + [ref]: /n/uni/thesis\n\ + not a link: /n/work/prose, [cased](/n/work/Plan), [file](/n/work/a.md)\n\ + [[Plan]], [[ Other plan |the other]], [[]], [[a|b|c]] links to a"; + let written = written(body); + let found: Vec<(Kind, &str)> = written + .iter() + .map(|(kind, target)| (*kind, target.as_str())) + .collect(); + assert_eq!( + found, + [ + (Kind::Path, "uni/thesis"), + (Kind::Path, "work/a/notes"), + (Kind::Path, "work/plan"), + (Kind::Wiki, "Other plan"), + (Kind::Wiki, "Plan"), + (Kind::Wiki, "a"), + ] + ); + } + + #[test] + fn a_rewrite_moves_only_the_link_it_names() { + let to = HashMap::from([ + ((Kind::Path, "work/a".to_string()), "work/b".to_string()), + ((Kind::Wiki, "Plan".to_string()), "Roadmap".to_string()), + ]); + let body = "[a](/n/work/a) [deeper](/n/work/a/x) [longer](/n/work/ab) [[Plan]] [[Plan|the plan]] [[Plans]]"; + assert_eq!( + rewrite(body, &to), + "[a](/n/work/b) [deeper](/n/work/a/x) [longer](/n/work/ab) [[Roadmap]] [[Roadmap|the plan]] [[Plans]]" + ); + } + + async fn space(state: &AppState, slug: &str) -> i64 { + sqlx::query_scalar("SELECT id FROM nodes WHERE slug = ?1 AND parent_id IS NULL") + .bind(slug) + .fetch_one(&state.db) + .await + .unwrap() + } + + async fn create(state: &AppState, parent: i64, kind: &str, name: &str, body: &str) -> i64 { + let input = serde_json::from_value(serde_json::json!({ + "parent_id": parent, "kind": kind, "name": name, "body": body, + })) + .unwrap(); + nodes::create(session(), State(state.clone()), axum::Json(input)) + .await + .unwrap() + .0 + .id + } + + async fn rename(state: &AppState, id: i64, name: &str) { + let body: String = sqlx::query_scalar("SELECT body FROM nodes WHERE id = ?1") + .bind(id) + .fetch_one(&state.db) + .await + .unwrap(); + let input = + serde_json::from_value(serde_json::json!({ "name": name, "body": body })).unwrap(); + let _renamed = nodes::update(session(), State(state.clone()), Path(id), axum::Json(input)) + .await + .unwrap(); + } + + async fn body(state: &AppState, id: i64) -> String { + sqlx::query_scalar("SELECT body FROM nodes WHERE id = ?1") + .bind(id) + .fetch_one(&state.db) + .await + .unwrap() + } + + async fn targets(state: &AppState, from: i64) -> Vec<(String, String, i64)> { + sqlx::query_as("SELECT kind, target, to_id FROM links WHERE from_id = ?1 ORDER BY kind") + .bind(from) + .fetch_all(&state.db) + .await + .unwrap() + } + + fn session() -> crate::auth::Session { + crate::auth::Session { id: "test".into() } + } + + /// A rename of the target rewrites the path and the title that link to + /// it; a rename of a folder above it rewrites the path. + #[tokio::test] + async fn a_rename_rewrites_what_links_to_it() { + let state = crate::routes::tests::state_in(".".into()).await; + let work = space(&state, "work").await; + let folder = create(&state, work, "folder", "Notes", "").await; + let plan = create(&state, folder, "document", "Plan", "the plan").await; + let linker = create( + &state, + work, + "document", + "Linker", + "see [it](/n/work/notes/plan#goals) and [[Plan]]", + ) + .await; + assert_eq!( + targets(&state, linker).await, + [ + ("path".to_string(), "work/notes/plan".to_string(), plan), + ("wiki".to_string(), "Plan".to_string(), plan), + ] + ); + + rename(&state, plan, "Roadmap").await; + assert_eq!( + body(&state, linker).await, + "see [it](/n/work/notes/roadmap#goals) and [[Roadmap]]" + ); + rename(&state, folder, "Ideas").await; + assert_eq!( + body(&state, linker).await, + "see [it](/n/work/ideas/roadmap#goals) and [[Roadmap]]" + ); + assert_eq!(targets(&state, linker).await.len(), 2); + + // Each rewrite kept what it replaced, as any other write of the text. + let kept: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM revisions WHERE node_id = ?1") + .bind(linker) + .fetch_one(&state.db) + .await + .unwrap(); + assert_eq!(kept, 1, "the second rewrite came within ten minutes"); + } + + /// A move into another folder rewrites the path; the title still finds it. + #[tokio::test] + async fn a_move_rewrites_the_path() { + let state = crate::routes::tests::state_in(".".into()).await; + let work = space(&state, "work").await; + let elsewhere = create(&state, work, "folder", "Elsewhere", "").await; + let plan = create(&state, work, "document", "Plan", "").await; + let linker = create( + &state, + work, + "document", + "Linker", + "[p](/n/work/plan) [[plan]]", + ) + .await; + + nodes::move_nodes(&state, &[plan], elsewhere, None) + .await + .unwrap(); + assert_eq!( + body(&state, linker).await, + "[p](/n/work/elsewhere/plan) [[plan]]" + ); + } + + /// A title is looked for in the linking document's space first. + #[tokio::test] + async fn a_title_resolves_in_its_own_space_first() { + let state = crate::routes::tests::state_in(".".into()).await; + let work = space(&state, "work").await; + let uni = space(&state, "uni").await; + let at_work = create(&state, work, "document", "Plan", "").await; + let at_uni = create(&state, uni, "document", "Plan", "").await; + let from_uni = create(&state, uni, "document", "a", "[[Plan]]").await; + let from_work = create(&state, work, "document", "b", "[[plan]]").await; + let only_uni = create(&state, work, "document", "c", "[[Thesis]]").await; + let thesis = create(&state, uni, "document", "Thesis", "").await; + + assert_eq!(targets(&state, from_uni).await[0].2, at_uni); + assert_eq!(targets(&state, from_work).await[0].2, at_work); + // Written before the thesis was, and claimed by it when it came. + assert_eq!(targets(&state, only_uni).await[0].2, thesis); + } + + /// The trash keeps the link; a restore elsewhere rewrites it, a purge + /// leaves the text as it was written. + #[tokio::test] + async fn the_trash_keeps_a_link_and_a_purge_drops_it() { + let state = crate::routes::tests::state_in(".".into()).await; + let work = space(&state, "work").await; + let folder = create(&state, work, "folder", "Notes", "").await; + let plan = create(&state, folder, "document", "Plan", "").await; + let linker = create( + &state, + work, + "document", + "Linker", + "[p](/n/work/notes/plan)", + ) + .await; + + crate::routes::trash::trash_subtree(&state.db, plan) + .await + .unwrap(); + // Written again while the plan is in the trash: the link stays known. + rename(&state, linker, "Linker").await; + assert_eq!(targets(&state, linker).await.len(), 1); + + nodes::purge_subtree(&state.db, std::path::Path::new("."), folder) + .await + .unwrap(); + // The folder is gone for good, so the plan goes back to the space. + crate::routes::trash::restore_node(&state, plan) + .await + .unwrap(); + assert_eq!(body(&state, linker).await, "[p](/n/work/plan)"); + + crate::routes::trash::trash_subtree(&state.db, plan) + .await + .unwrap(); + crate::routes::trash::purge_older(&state.db, std::path::Path::new("."), i64::MAX) + .await + .unwrap(); + assert!(targets(&state, linker).await.is_empty()); + assert_eq!(body(&state, linker).await, "[p](/n/work/plan)"); + } + + /// The documents written before links were kept are indexed once, and + /// only while nothing is. + #[tokio::test] + async fn the_backfill_indexes_what_came_before() { + let state = crate::routes::tests::state_in(".".into()).await; + let work = space(&state, "work").await; + let plan = create(&state, work, "document", "Plan", "").await; + let linker = create(&state, work, "document", "Linker", "[[Plan]]").await; + sqlx::query("DELETE FROM links") + .execute(&state.db) + .await + .unwrap(); + + assert_eq!(super::backfill(&state.db).await.unwrap(), 1); + assert_eq!(targets(&state, linker).await[0].2, plan); + assert_eq!(super::backfill(&state.db).await.unwrap(), 0); + } +} diff --git a/src/routes/mod.rs b/src/routes/mod.rs index a9748bb..a0dbaec 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -3,6 +3,7 @@ pub mod boards; pub mod export; pub mod import; pub mod inspect; +pub mod links; pub mod manifest; pub mod media; pub mod nodes; @@ -71,6 +72,7 @@ pub fn api_router(max_upload: usize) -> Router { .route("/nodes/{id}", put(nodes::update)) .route("/nodes/{id}", delete(nodes::remove)) .route("/nodes/{id}/revisions", get(revisions::list)) + .route("/nodes/{id}/backlinks", get(links::backlinks)) .route("/nodes/{id}/revisions/{rev}", get(revisions::get)) .route("/nodes/move", post(nodes::move_many)) .route("/nodes/{id}/move", post(nodes::move_node)) diff --git a/src/routes/nodes.rs b/src/routes/nodes.rs index aefeb65..7b7482d 100644 --- a/src/routes/nodes.rs +++ b/src/routes/nodes.rs @@ -1,4 +1,4 @@ -use std::collections::{BTreeSet, HashSet}; +use std::collections::{BTreeMap, BTreeSet, HashSet}; use axum::{ extract::{Path, Query, State}, @@ -10,7 +10,7 @@ use sqlx::{Row, SqliteConnection, SqlitePool}; use crate::{ auth::Session, error::{AppError, AppResult}, - routes::{media, revisions, trash}, + routes::{links, media, revisions, trash}, state::AppState, }; @@ -104,6 +104,11 @@ pub struct Node { /// This node's ancestors, the space first and the node itself last — the /// breadcrumb, and everything needed to build its /n/... path. pub path: Vec, + /// How many documents link here. + pub backlinks: i64, + /// Where each `[[title]]` in the body leads, as an /n/... path. A title + /// that leads nowhere is not in it. + pub wikilinks: BTreeMap, } #[derive(Serialize)] @@ -595,21 +600,9 @@ pub async fn resolve( State(state): State, Query(params): Query, ) -> AppResult> { - let mut current: Option = None; - for segment in params.path.split('/').filter(|s| !s.is_empty()) { - // A path starts at a space, never at the trash beside them. - let row = sqlx::query( - "SELECT id FROM nodes - WHERE parent_id IS ?1 AND slug = ?2 AND (?1 IS NOT NULL OR kind = 'space')", - ) - .bind(current) - .bind(segment) - .fetch_optional(&state.db) - .await? - .ok_or(AppError::NotFound)?; - current = Some(row.get("id")); - } - load(&state, current.ok_or(AppError::NotFound)?) + // A path starts at a space, never at the trash beside them. + let found = links::resolve_path(&mut *state.db.acquire().await?, ¶ms.path).await?; + load(&state, found.ok_or(AppError::NotFound)?) .await .map(Json) } @@ -650,6 +643,9 @@ pub async fn create( .get("id"); media::link_to_node(&mut tx, id, &input.body).await?; + links::index(&mut tx, id, &input.body).await?; + // A `[[link]]` written before the document it names now finds it. + links::follow(&mut tx, id).await?; tx.commit().await?; state.backup.signal(); load(&state, id).await.map(Json) @@ -743,6 +739,12 @@ pub async fn update( if kind != FILE { media::link_to_node(&mut tx, id, body).await?; + links::index(&mut tx, id, body).await?; + } + // A new name is a new path, for this and everything under it, and a new + // title: the links written with the old ones are rewritten to find it. + if name != previous_name { + links::follow(&mut tx, id).await?; } tx.commit().await?; state.backup.signal(); @@ -878,11 +880,13 @@ pub async fn move_nodes( moved: 0, unchanged: 0, }; + let mut moved = Vec::with_capacity(nodes.len()); for (id, name, from) in nodes { if from == Some(parent) && position.is_none() { done.unchanged += 1; continue; } + moved.push(id); // The slug has to be free where it lands, not where it came from — // and it sees the ones moved in before it, so two `notes` stay apart. let slug = unique_slug(&mut tx, Some(parent), &slugify(&name), Some(id)).await?; @@ -900,6 +904,11 @@ pub async fn move_nodes( .await?; done.moved += 1; } + // Once all of them are where they go, so a link between two of them is + // rewritten to where both now are. + for id in moved { + links::follow(&mut tx, id).await?; + } tx.commit().await?; if done.moved > 0 { @@ -1023,6 +1032,13 @@ pub async fn load(state: &AppState, id: i64) -> AppResult { let body: String = row.get("body"); let summary = row_to_summary(&row, &body.chars().take(HEAD_CHARS).collect::()); + let (backlinks, wikilinks) = { + let mut conn = state.db.acquire().await?; + ( + links::linked_from(&mut conn, id).await?, + links::wikilinks(&mut conn, id, &body).await?, + ) + }; Ok(Node { id: summary.id, @@ -1043,6 +1059,8 @@ pub async fn load(state: &AppState, id: i64) -> AppResult { sealed: summary.sealed, file: summary.file, path: path_of(&state.db, id).await?, + backlinks, + wikilinks, }) } diff --git a/src/routes/trash.rs b/src/routes/trash.rs index 5a9b3f4..86255cd 100644 --- a/src/routes/trash.rs +++ b/src/routes/trash.rs @@ -248,6 +248,8 @@ pub async fn restore_node(state: &AppState, id: i64) -> AppResult { .bind(id) .execute(&mut *tx) .await?; + // Under another slug when its own was taken meanwhile. + crate::routes::links::follow(&mut tx, id).await?; tx.commit().await?; return Ok(false); } diff --git a/src/sftp/fs.rs b/src/sftp/fs.rs index a99aeda..154a1ad 100644 --- a/src/sftp/fs.rs +++ b/src/sftp/fs.rs @@ -35,7 +35,7 @@ use crate::{ Backup, }, error::{AppError, AppResult}, - routes::{media, nodes, revisions, seal, trash}, + routes::{links, media, nodes, revisions, seal, trash}, state::AppState, vault, }; @@ -778,6 +778,14 @@ impl SpaceFs { media::link_to_node(&mut tx, id, &body) .await .map_err(|_| StatusCode::Failure)?; + links::index(&mut tx, id, &body) + .await + .map_err(|_| StatusCode::Failure)?; + // A heading may have renamed it, and a `[[link]]` to the old title + // then follows it; a new document is what some link was waiting for. + links::follow(&mut tx, id) + .await + .map_err(|_| StatusCode::Failure)?; tx.commit().await.map_err(|_| StatusCode::Failure)?; self.changed(); Ok(()) @@ -1097,6 +1105,9 @@ impl SpaceFs { .execute(&mut *tx) .await .map_err(|_| StatusCode::Failure)?; + links::follow(&mut tx, file.id) + .await + .map_err(|_| StatusCode::Failure)?; tx.commit().await.map_err(|_| StatusCode::Failure)?; self.changed(); Ok(()) @@ -1715,6 +1726,9 @@ impl russh_sftp::server::Handler for SpaceFs { .map_err(|_| StatusCode::Failure)?; // A move that keeps the name keeps the title too. let name = (new_leaf != slug).then_some(new_leaf); + let mut tx = crate::db::begin_write(&self.db) + .await + .map_err(|_| StatusCode::Failure)?; sqlx::query( "UPDATE nodes SET parent_id = ?1, name = COALESCE(?2, name), slug = ?3, updated_at = ?4 WHERE id = ?5", @@ -1724,9 +1738,13 @@ impl russh_sftp::server::Handler for SpaceFs { .bind(&new_slug) .bind(now) .bind(node) - .execute(&self.db) + .execute(&mut *tx) .await .map_err(|_| StatusCode::Failure)?; + links::follow(&mut tx, node) + .await + .map_err(|_| StatusCode::Failure)?; + tx.commit().await.map_err(|_| StatusCode::Failure)?; self.changed(); } @@ -1754,6 +1772,9 @@ impl russh_sftp::server::Handler for SpaceFs { ) .await .map_err(|_| StatusCode::Failure)?; + let mut tx = crate::db::begin_write(&self.db) + .await + .map_err(|_| StatusCode::Failure)?; sqlx::query( "UPDATE nodes SET parent_id = ?1, slug = ?2, updated_at = ?3 WHERE id = ?4", ) @@ -1761,9 +1782,13 @@ impl russh_sftp::server::Handler for SpaceFs { .bind(&new_slug) .bind(now) .bind(doc.id) - .execute(&self.db) + .execute(&mut *tx) .await .map_err(|_| StatusCode::Failure)?; + links::follow(&mut tx, doc.id) + .await + .map_err(|_| StatusCode::Failure)?; + tx.commit().await.map_err(|_| StatusCode::Failure)?; self.changed(); } // Onto a name that is not a document: an editor moving the @@ -2503,6 +2528,36 @@ mod tests { assert_eq!(kept, [("v1\n".to_string(), "sftp".to_string())]); } + /// A move over the mount, and a heading that renames, rewrite the links + /// to the document as a move and a rename in the browser do. + #[tokio::test] + async fn links_follow_a_document_moved_over_the_mount() { + let mut fs = fs().await; + fs.mkdir(0, "/proj".into(), FileAttributes::empty()) + .await + .expect("mkdir"); + fs.mkdir(0, "/proj/old".into(), FileAttributes::empty()) + .await + .expect("mkdir"); + put(&mut fs, "/proj/plan.md", "# Plan\n\nv1\n").await; + put( + &mut fs, + "/proj/notes.md", + "# Notes\n\nsee [it](/n/proj/plan) and [[Plan]]\n", + ) + .await; + + fs.rename(0, "/proj/plan.md".into(), "/proj/old/roadmap.md".into()) + .await + .expect("rename"); + let (.., body) = node(&fs, "notes").await; + assert_eq!(body, "see [it](/n/proj/old/roadmap) and [[Plan]]\n"); + + put(&mut fs, "/proj/old/roadmap.md", "# Roadmap\n\nv2\n").await; + let (.., body) = node(&fs, "notes").await; + assert_eq!(body, "see [it](/n/proj/old/roadmap) and [[Roadmap]]\n"); + } + #[tokio::test] async fn spaces_rename_at_the_root_and_folders_cannot_swallow_themselves() { let mut fs = fs().await; diff --git a/web/bun.lock b/web/bun.lock index 9990ec8..ee3b340 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -5,6 +5,7 @@ "": { "name": "narl-diary-web", "dependencies": { + "@codemirror/autocomplete": "^6.20.3", "@codemirror/commands": "^6.7.1", "@codemirror/lang-markdown": "^6.3.1", "@codemirror/language": "^6.10.8", diff --git a/web/package.json b/web/package.json index d35fb19..d1d9799 100644 --- a/web/package.json +++ b/web/package.json @@ -17,6 +17,7 @@ "vite": "^6.0.11" }, "dependencies": { + "@codemirror/autocomplete": "^6.20.3", "@codemirror/commands": "^6.7.1", "@codemirror/lang-markdown": "^6.3.1", "@codemirror/language": "^6.10.8", diff --git a/web/src/App.svelte b/web/src/App.svelte index e5d2d03..0f800d2 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -15,6 +15,7 @@ import Overview from './lib/Overview.svelte' import MediaBrowser, { copyMedia, insertMedia, removeMedia } from './lib/MediaBrowser.svelte' import Trash, { purgeTrashed, restoreTrashed } from './lib/Trash.svelte' + import Backlinks, { openBacklink } from './lib/Backlinks.svelte' import History, { moveHistory, restoreShown } from './lib/History.svelte' import Actions from './lib/Actions.svelte' import Prompt from './lib/Prompt.svelte' @@ -422,6 +423,31 @@ } } + /** The backlinks' keys: j/k through the documents that link here, Enter opens one. */ + function backlinksKey(event: KeyboardEvent): boolean { + const last = Math.max(workspace.backlinks.length - 1, 0) + switch (event.key) { + case 'j': + case 'ArrowDown': + workspace.backlinksCursor = Math.min(workspace.backlinksCursor + 1, last) + return true + case 'k': + case 'ArrowUp': + workspace.backlinksCursor = Math.max(workspace.backlinksCursor - 1, 0) + return true + case 'G': + workspace.backlinksCursor = last + return true + case 'Enter': { + const link = workspace.backlinks[workspace.backlinksCursor] + if (link) void openBacklink(link) + return true + } + default: + return false + } + } + /** The history's keys: j/k through the versions, Enter brings the one shown back. */ function historyKey(event: KeyboardEvent): boolean { switch (event.key) { @@ -637,6 +663,12 @@ return } } + if (workspace.overlay === 'backlinks' && listening && !event.ctrlKey && !event.metaKey && !event.altKey) { + if (backlinksKey(event)) { + event.preventDefault() + return + } + } if (workspace.overlay === 'trash' && listening && !event.ctrlKey && !event.metaKey && !event.altKey) { if (trashKey(event)) { event.preventDefault() @@ -793,6 +825,11 @@ void runCommand('history') return } + if (previous === 'g' && key === 'b') { + event.preventDefault() + void runCommand('backlinks') + return + } if (previous === 'g' && key === 'x') { event.preventDefault() openInTab() @@ -1075,7 +1112,7 @@
e.target === e.currentTarget && (workspace.overlay = 'none')} > - {#if workspace.overlay === 'help'}{:else if workspace.overlay === 'overview'}{:else if workspace.overlay === 'agenda'}{:else if workspace.overlay === 'actions'}{:else if workspace.overlay === 'verify'}{:else if workspace.overlay === 'outline'}{:else if workspace.overlay === 'trash'}{:else if workspace.overlay === 'history'}{:else}{/if} + {#if workspace.overlay === 'help'}{:else if workspace.overlay === 'overview'}{:else if workspace.overlay === 'agenda'}{:else if workspace.overlay === 'actions'}{:else if workspace.overlay === 'verify'}{:else if workspace.overlay === 'outline'}{:else if workspace.overlay === 'trash'}{:else if workspace.overlay === 'history'}{:else if workspace.overlay === 'backlinks'}{:else}{/if}
{/if} diff --git a/web/src/app.css b/web/src/app.css index a60293a..c32e7b8 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -93,6 +93,8 @@ a { } a:hover { color: var(--light); text-decoration-color: var(--light); } a:focus-visible { background: var(--light); color: var(--ink); text-decoration: none; outline: none; } +/* A `[[title]]` no document has yet: there, but not written. */ +a.wikilink.missing { color: var(--muted); text-decoration-style: dashed; } /* * A button rests as a hairline and a label. Hover lifts it a step and lights diff --git a/web/src/lib/Backlinks.svelte b/web/src/lib/Backlinks.svelte new file mode 100644 index 0000000..f32b372 --- /dev/null +++ b/web/src/lib/Backlinks.svelte @@ -0,0 +1,101 @@ + + + + +
+
+ + backlinks + {workspace.open?.name.trim() || workspace.open?.slug} · linked from {workspace.backlinks.length} + + +
+ +
    + {#each workspace.backlinks as link, index (link.id)} + +
  • (workspace.backlinksCursor = index)} + onclick={() => openBacklink(link)} + > + {link.name.trim() || link.slug} + {link.path} + {#if link.context}{link.context}{/if} +
  • + {:else} +
  • nothing links here yet — a link is [text](/n/…) or [[title]].
  • + {/each} +
+ +
+ jk move + + enter open + + qesc close +
+
+ + diff --git a/web/src/lib/DocumentPane.svelte b/web/src/lib/DocumentPane.svelte index 01e0e72..3955550 100644 --- a/web/src/lib/DocumentPane.svelte +++ b/web/src/lib/DocumentPane.svelte @@ -21,7 +21,7 @@ const focused = $derived(workspace.tile === tile) const doc = $derived(tile.open!) - const html = $derived(renderMarkdown(tile.editing ? tile.draft.body : doc.body)) + const html = $derived(renderMarkdown(tile.editing ? tile.draft.body : doc.body, { wikilinks: doc.wikilinks })) const words = $derived( (tile.editing ? tile.draft.body : doc.body).split(/\s+/).filter(Boolean).length, ) @@ -161,6 +161,10 @@
{relative(doc.updated_at)}
words
{words}words
+ {#if doc.backlinks > 0} +
linked
+
+ {/if}
@@ -298,6 +302,9 @@ } .meta dt { color: var(--muted); letter-spacing: 0.18em; text-transform: uppercase; } .meta dd { margin: 0 10px 0 0; color: var(--fg); } + /* Read as the rest of the line, and still a way to the backlinks. */ + .meta .linked { all: unset; cursor: pointer; text-decoration: underline; text-decoration-color: var(--overlay); text-underline-offset: 4px; } + .meta .linked:hover, .meta .linked:focus-visible { color: var(--light); } .actions { display: flex; gap: 4px; flex-wrap: wrap; flex: none; } .actions .on { color: var(--light); border-color: var(--light-dim); } diff --git a/web/src/lib/Editor.svelte b/web/src/lib/Editor.svelte index 21331cb..cd553a8 100644 --- a/web/src/lib/Editor.svelte +++ b/web/src/lib/Editor.svelte @@ -133,6 +133,7 @@