mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
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.
This commit is contained in:
1 parent
fc2be66b0c
commit
a42f9074a2
23 files changed
+1281
-35
No files matched your search
@@ -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`) |
|
| `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 |
|
| `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`) |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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 `↓`
|
`: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
|
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
|
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
|
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,
|
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
|
`: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`
|
highlighted one at the cursor while writing, `y` copies its address and `x`
|
||||||
deletes it, asking first.
|
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
|
## Files
|
||||||
|
|
||||||
A file is a node like a document, only with bytes instead of text: it sits in
|
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/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` | a document's earlier versions, the newest first |
|
||||||
| `GET` | `/api/nodes/{id}/revisions/{rev}` | one earlier version, with its text |
|
| `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 |
|
| `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` | `/api/export` | the whole workspace, boards and dates included, as a zip |
|
||||||
| `GET`/`POST` | `/api/backup` | Proton Drive mirror: status / run now |
|
| `GET`/`POST` | `/api/backup` | Proton Drive mirror: status / run now |
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -143,6 +143,13 @@ async fn main() -> Result<()> {
|
|||||||
sessions,
|
sessions,
|
||||||
};
|
};
|
||||||
routes::seal::spawn_rekey(state.clone());
|
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 {
|
if let Some(sftp_bind) = config.sftp.bind {
|
||||||
let sftp_state = state.clone();
|
let sftp_state = state.clone();
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ use crate::{
|
|||||||
auth::Session,
|
auth::Session,
|
||||||
error::{AppError, AppResult},
|
error::{AppError, AppResult},
|
||||||
routes::{
|
routes::{
|
||||||
boards,
|
boards, links,
|
||||||
manifest::{self, Manifest, NodeMeta},
|
manifest::{self, Manifest, NodeMeta},
|
||||||
media,
|
media,
|
||||||
nodes::{self, next_slug, slugify, DOCUMENT, FILE, FOLDER, SPACE},
|
nodes::{self, next_slug, slugify, DOCUMENT, FILE, FOLDER, SPACE},
|
||||||
@@ -968,6 +968,7 @@ async fn build(
|
|||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
media::link_to_node(&mut tx, *id, &rewritten).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.
|
// The boards come back only where spaces do.
|
||||||
|
|||||||
@@ -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<Regex> = LazyLock::new(|| {
|
||||||
|
Regex::new(r"(?m)(?:\]\(\s*<?|^[ \t]{0,3}\[[^\]\n]+\]:[ \t]*<?)/n/([a-z0-9-]+(?:/[a-z0-9-]+)*)")
|
||||||
|
.expect("static regex is valid")
|
||||||
|
});
|
||||||
|
|
||||||
|
/// `[[title]]`, or `[[title|what the link reads]]`.
|
||||||
|
static WIKILINK: LazyLock<Regex> = 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<Item = regex::Match<'_>> {
|
||||||
|
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<Option<i64>> {
|
||||||
|
let mut current: Option<i64> = None;
|
||||||
|
for segment in path.split('/').filter(|s| !s.is_empty()) {
|
||||||
|
let found: Option<i64> = 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<Option<i64>> {
|
||||||
|
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<i64>,
|
||||||
|
) -> AppResult<Option<i64>> {
|
||||||
|
// `[[???]]` 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::<i64, _>("space") == space);
|
||||||
|
(here, row.get::<bool, _>("named"), Reverse(origin))
|
||||||
|
})
|
||||||
|
.max()
|
||||||
|
.map(|(_, _, Reverse(origin))| origin))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resolve(
|
||||||
|
db: &mut SqliteConnection,
|
||||||
|
kind: Kind,
|
||||||
|
target: &str,
|
||||||
|
space: Option<i64>,
|
||||||
|
) -> AppResult<Option<i64>> {
|
||||||
|
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<String> {
|
||||||
|
let slugs: Vec<String> = 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<String> {
|
||||||
|
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<usize> {
|
||||||
|
// 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<i64, Vec<(Kind, String, i64)>> = 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::<String, _>("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<Vec<i64>> {
|
||||||
|
let from: Vec<i64> = 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<i64> {
|
||||||
|
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<BTreeMap<String, String>> {
|
||||||
|
let mut found = BTreeMap::new();
|
||||||
|
let titles: Vec<String> = 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<AppState>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
) -> AppResult<Json<Vec<Backlink>>> {
|
||||||
|
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<String> =
|
||||||
|
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::<String, _>("target")),
|
||||||
|
Kind::Wiki => format!("[[{}", link.get::<String, _>("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<usize> {
|
||||||
|
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/work/a/notes#goals>)\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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ pub mod boards;
|
|||||||
pub mod export;
|
pub mod export;
|
||||||
pub mod import;
|
pub mod import;
|
||||||
pub mod inspect;
|
pub mod inspect;
|
||||||
|
pub mod links;
|
||||||
pub mod manifest;
|
pub mod manifest;
|
||||||
pub mod media;
|
pub mod media;
|
||||||
pub mod nodes;
|
pub mod nodes;
|
||||||
@@ -71,6 +72,7 @@ pub fn api_router(max_upload: usize) -> Router<AppState> {
|
|||||||
.route("/nodes/{id}", put(nodes::update))
|
.route("/nodes/{id}", put(nodes::update))
|
||||||
.route("/nodes/{id}", delete(nodes::remove))
|
.route("/nodes/{id}", delete(nodes::remove))
|
||||||
.route("/nodes/{id}/revisions", get(revisions::list))
|
.route("/nodes/{id}/revisions", get(revisions::list))
|
||||||
|
.route("/nodes/{id}/backlinks", get(links::backlinks))
|
||||||
.route("/nodes/{id}/revisions/{rev}", get(revisions::get))
|
.route("/nodes/{id}/revisions/{rev}", get(revisions::get))
|
||||||
.route("/nodes/move", post(nodes::move_many))
|
.route("/nodes/move", post(nodes::move_many))
|
||||||
.route("/nodes/{id}/move", post(nodes::move_node))
|
.route("/nodes/{id}/move", post(nodes::move_node))
|
||||||
|
|||||||
+35
-17
@@ -1,4 +1,4 @@
|
|||||||
use std::collections::{BTreeSet, HashSet};
|
use std::collections::{BTreeMap, BTreeSet, HashSet};
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
@@ -10,7 +10,7 @@ use sqlx::{Row, SqliteConnection, SqlitePool};
|
|||||||
use crate::{
|
use crate::{
|
||||||
auth::Session,
|
auth::Session,
|
||||||
error::{AppError, AppResult},
|
error::{AppError, AppResult},
|
||||||
routes::{media, revisions, trash},
|
routes::{links, media, revisions, trash},
|
||||||
state::AppState,
|
state::AppState,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -104,6 +104,11 @@ pub struct Node {
|
|||||||
/// This node's ancestors, the space first and the node itself last — the
|
/// This node's ancestors, the space first and the node itself last — the
|
||||||
/// breadcrumb, and everything needed to build its /n/... path.
|
/// breadcrumb, and everything needed to build its /n/... path.
|
||||||
pub path: Vec<Crumb>,
|
pub path: Vec<Crumb>,
|
||||||
|
/// 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<String, String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -595,21 +600,9 @@ pub async fn resolve(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Query(params): Query<ResolveQuery>,
|
Query(params): Query<ResolveQuery>,
|
||||||
) -> AppResult<Json<Node>> {
|
) -> AppResult<Json<Node>> {
|
||||||
let mut current: Option<i64> = None;
|
// A path starts at a space, never at the trash beside them.
|
||||||
for segment in params.path.split('/').filter(|s| !s.is_empty()) {
|
let found = links::resolve_path(&mut *state.db.acquire().await?, ¶ms.path).await?;
|
||||||
// A path starts at a space, never at the trash beside them.
|
load(&state, found.ok_or(AppError::NotFound)?)
|
||||||
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)?)
|
|
||||||
.await
|
.await
|
||||||
.map(Json)
|
.map(Json)
|
||||||
}
|
}
|
||||||
@@ -650,6 +643,9 @@ pub async fn create(
|
|||||||
.get("id");
|
.get("id");
|
||||||
|
|
||||||
media::link_to_node(&mut tx, id, &input.body).await?;
|
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?;
|
tx.commit().await?;
|
||||||
state.backup.signal();
|
state.backup.signal();
|
||||||
load(&state, id).await.map(Json)
|
load(&state, id).await.map(Json)
|
||||||
@@ -743,6 +739,12 @@ pub async fn update(
|
|||||||
|
|
||||||
if kind != FILE {
|
if kind != FILE {
|
||||||
media::link_to_node(&mut tx, id, body).await?;
|
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?;
|
tx.commit().await?;
|
||||||
state.backup.signal();
|
state.backup.signal();
|
||||||
@@ -878,11 +880,13 @@ pub async fn move_nodes(
|
|||||||
moved: 0,
|
moved: 0,
|
||||||
unchanged: 0,
|
unchanged: 0,
|
||||||
};
|
};
|
||||||
|
let mut moved = Vec::with_capacity(nodes.len());
|
||||||
for (id, name, from) in nodes {
|
for (id, name, from) in nodes {
|
||||||
if from == Some(parent) && position.is_none() {
|
if from == Some(parent) && position.is_none() {
|
||||||
done.unchanged += 1;
|
done.unchanged += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
moved.push(id);
|
||||||
// The slug has to be free where it lands, not where it came from —
|
// 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.
|
// 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?;
|
let slug = unique_slug(&mut tx, Some(parent), &slugify(&name), Some(id)).await?;
|
||||||
@@ -900,6 +904,11 @@ pub async fn move_nodes(
|
|||||||
.await?;
|
.await?;
|
||||||
done.moved += 1;
|
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?;
|
tx.commit().await?;
|
||||||
|
|
||||||
if done.moved > 0 {
|
if done.moved > 0 {
|
||||||
@@ -1023,6 +1032,13 @@ pub async fn load(state: &AppState, id: i64) -> AppResult<Node> {
|
|||||||
|
|
||||||
let body: String = row.get("body");
|
let body: String = row.get("body");
|
||||||
let summary = row_to_summary(&row, &body.chars().take(HEAD_CHARS).collect::<String>());
|
let summary = row_to_summary(&row, &body.chars().take(HEAD_CHARS).collect::<String>());
|
||||||
|
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 {
|
Ok(Node {
|
||||||
id: summary.id,
|
id: summary.id,
|
||||||
@@ -1043,6 +1059,8 @@ pub async fn load(state: &AppState, id: i64) -> AppResult<Node> {
|
|||||||
sealed: summary.sealed,
|
sealed: summary.sealed,
|
||||||
file: summary.file,
|
file: summary.file,
|
||||||
path: path_of(&state.db, id).await?,
|
path: path_of(&state.db, id).await?,
|
||||||
|
backlinks,
|
||||||
|
wikilinks,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -248,6 +248,8 @@ pub async fn restore_node(state: &AppState, id: i64) -> AppResult<bool> {
|
|||||||
.bind(id)
|
.bind(id)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
|
// Under another slug when its own was taken meanwhile.
|
||||||
|
crate::routes::links::follow(&mut tx, id).await?;
|
||||||
tx.commit().await?;
|
tx.commit().await?;
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
|
|||||||
+58
-3
@@ -35,7 +35,7 @@ use crate::{
|
|||||||
Backup,
|
Backup,
|
||||||
},
|
},
|
||||||
error::{AppError, AppResult},
|
error::{AppError, AppResult},
|
||||||
routes::{media, nodes, revisions, seal, trash},
|
routes::{links, media, nodes, revisions, seal, trash},
|
||||||
state::AppState,
|
state::AppState,
|
||||||
vault,
|
vault,
|
||||||
};
|
};
|
||||||
@@ -778,6 +778,14 @@ impl SpaceFs {
|
|||||||
media::link_to_node(&mut tx, id, &body)
|
media::link_to_node(&mut tx, id, &body)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::Failure)?;
|
.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)?;
|
tx.commit().await.map_err(|_| StatusCode::Failure)?;
|
||||||
self.changed();
|
self.changed();
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1097,6 +1105,9 @@ impl SpaceFs {
|
|||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::Failure)?;
|
.map_err(|_| StatusCode::Failure)?;
|
||||||
|
links::follow(&mut tx, file.id)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::Failure)?;
|
||||||
tx.commit().await.map_err(|_| StatusCode::Failure)?;
|
tx.commit().await.map_err(|_| StatusCode::Failure)?;
|
||||||
self.changed();
|
self.changed();
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1715,6 +1726,9 @@ impl russh_sftp::server::Handler for SpaceFs {
|
|||||||
.map_err(|_| StatusCode::Failure)?;
|
.map_err(|_| StatusCode::Failure)?;
|
||||||
// A move that keeps the name keeps the title too.
|
// A move that keeps the name keeps the title too.
|
||||||
let name = (new_leaf != slug).then_some(new_leaf);
|
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(
|
sqlx::query(
|
||||||
"UPDATE nodes SET parent_id = ?1, name = COALESCE(?2, name), slug = ?3,
|
"UPDATE nodes SET parent_id = ?1, name = COALESCE(?2, name), slug = ?3,
|
||||||
updated_at = ?4 WHERE id = ?5",
|
updated_at = ?4 WHERE id = ?5",
|
||||||
@@ -1724,9 +1738,13 @@ impl russh_sftp::server::Handler for SpaceFs {
|
|||||||
.bind(&new_slug)
|
.bind(&new_slug)
|
||||||
.bind(now)
|
.bind(now)
|
||||||
.bind(node)
|
.bind(node)
|
||||||
.execute(&self.db)
|
.execute(&mut *tx)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::Failure)?;
|
.map_err(|_| StatusCode::Failure)?;
|
||||||
|
links::follow(&mut tx, node)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::Failure)?;
|
||||||
|
tx.commit().await.map_err(|_| StatusCode::Failure)?;
|
||||||
self.changed();
|
self.changed();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1754,6 +1772,9 @@ impl russh_sftp::server::Handler for SpaceFs {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::Failure)?;
|
.map_err(|_| StatusCode::Failure)?;
|
||||||
|
let mut tx = crate::db::begin_write(&self.db)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::Failure)?;
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"UPDATE nodes SET parent_id = ?1, slug = ?2, updated_at = ?3 WHERE id = ?4",
|
"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(&new_slug)
|
||||||
.bind(now)
|
.bind(now)
|
||||||
.bind(doc.id)
|
.bind(doc.id)
|
||||||
.execute(&self.db)
|
.execute(&mut *tx)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::Failure)?;
|
.map_err(|_| StatusCode::Failure)?;
|
||||||
|
links::follow(&mut tx, doc.id)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::Failure)?;
|
||||||
|
tx.commit().await.map_err(|_| StatusCode::Failure)?;
|
||||||
self.changed();
|
self.changed();
|
||||||
}
|
}
|
||||||
// Onto a name that is not a document: an editor moving the
|
// 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())]);
|
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]
|
#[tokio::test]
|
||||||
async fn spaces_rename_at_the_root_and_folders_cannot_swallow_themselves() {
|
async fn spaces_rename_at_the_root_and_folders_cannot_swallow_themselves() {
|
||||||
let mut fs = fs().await;
|
let mut fs = fs().await;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "narl-diary-web",
|
"name": "narl-diary-web",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@codemirror/autocomplete": "^6.20.3",
|
||||||
"@codemirror/commands": "^6.7.1",
|
"@codemirror/commands": "^6.7.1",
|
||||||
"@codemirror/lang-markdown": "^6.3.1",
|
"@codemirror/lang-markdown": "^6.3.1",
|
||||||
"@codemirror/language": "^6.10.8",
|
"@codemirror/language": "^6.10.8",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"vite": "^6.0.11"
|
"vite": "^6.0.11"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@codemirror/autocomplete": "^6.20.3",
|
||||||
"@codemirror/commands": "^6.7.1",
|
"@codemirror/commands": "^6.7.1",
|
||||||
"@codemirror/lang-markdown": "^6.3.1",
|
"@codemirror/lang-markdown": "^6.3.1",
|
||||||
"@codemirror/language": "^6.10.8",
|
"@codemirror/language": "^6.10.8",
|
||||||
|
|||||||
+38
-1
@@ -15,6 +15,7 @@
|
|||||||
import Overview from './lib/Overview.svelte'
|
import Overview from './lib/Overview.svelte'
|
||||||
import MediaBrowser, { copyMedia, insertMedia, removeMedia } from './lib/MediaBrowser.svelte'
|
import MediaBrowser, { copyMedia, insertMedia, removeMedia } from './lib/MediaBrowser.svelte'
|
||||||
import Trash, { purgeTrashed, restoreTrashed } from './lib/Trash.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 History, { moveHistory, restoreShown } from './lib/History.svelte'
|
||||||
import Actions from './lib/Actions.svelte'
|
import Actions from './lib/Actions.svelte'
|
||||||
import Prompt from './lib/Prompt.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. */
|
/** The history's keys: j/k through the versions, Enter brings the one shown back. */
|
||||||
function historyKey(event: KeyboardEvent): boolean {
|
function historyKey(event: KeyboardEvent): boolean {
|
||||||
switch (event.key) {
|
switch (event.key) {
|
||||||
@@ -637,6 +663,12 @@
|
|||||||
return
|
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 (workspace.overlay === 'trash' && listening && !event.ctrlKey && !event.metaKey && !event.altKey) {
|
||||||
if (trashKey(event)) {
|
if (trashKey(event)) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@@ -793,6 +825,11 @@
|
|||||||
void runCommand('history')
|
void runCommand('history')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (previous === 'g' && key === 'b') {
|
||||||
|
event.preventDefault()
|
||||||
|
void runCommand('backlinks')
|
||||||
|
return
|
||||||
|
}
|
||||||
if (previous === 'g' && key === 'x') {
|
if (previous === 'g' && key === 'x') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
openInTab()
|
openInTab()
|
||||||
@@ -1075,7 +1112,7 @@
|
|||||||
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions, a11y_no_noninteractive_tabindex -->
|
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions, a11y_no_noninteractive_tabindex -->
|
||||||
<div class="overlay" tabindex="-1" bind:this={overlayEl} onclick={(e) => e.target === e.currentTarget && (workspace.overlay = 'none')}
|
<div class="overlay" tabindex="-1" bind:this={overlayEl} onclick={(e) => e.target === e.currentTarget && (workspace.overlay = 'none')}
|
||||||
>
|
>
|
||||||
{#if workspace.overlay === 'help'}<Help />{:else if workspace.overlay === 'overview'}<Overview />{:else if workspace.overlay === 'agenda'}<Agenda />{:else if workspace.overlay === 'actions'}<Actions />{:else if workspace.overlay === 'verify'}<Verify />{:else if workspace.overlay === 'outline'}<Headings />{:else if workspace.overlay === 'trash'}<Trash />{:else if workspace.overlay === 'history'}<History />{:else}<MediaBrowser />{/if}
|
{#if workspace.overlay === 'help'}<Help />{:else if workspace.overlay === 'overview'}<Overview />{:else if workspace.overlay === 'agenda'}<Agenda />{:else if workspace.overlay === 'actions'}<Actions />{:else if workspace.overlay === 'verify'}<Verify />{:else if workspace.overlay === 'outline'}<Headings />{:else if workspace.overlay === 'trash'}<Trash />{:else if workspace.overlay === 'history'}<History />{:else if workspace.overlay === 'backlinks'}<Backlinks />{:else}<MediaBrowser />{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -93,6 +93,8 @@ a {
|
|||||||
}
|
}
|
||||||
a:hover { color: var(--light); text-decoration-color: var(--light); }
|
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: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
|
* A button rests as a hairline and a label. Hover lifts it a step and lights
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<script lang="ts" module>
|
||||||
|
import type { Backlink } from './api'
|
||||||
|
import { workspace } from './store.svelte'
|
||||||
|
|
||||||
|
/** What Enter and a click do: the linking document is opened. */
|
||||||
|
export function openBacklink(link: Backlink) {
|
||||||
|
workspace.overlay = 'none'
|
||||||
|
return workspace.guard(() => workspace.openPath(link.path))
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
let sheet = $state<HTMLDivElement>()
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const index = workspace.backlinksCursor
|
||||||
|
sheet?.querySelector<HTMLElement>(`[data-index="${index}"]`)?.scrollIntoView({ block: 'nearest' })
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="sheet" bind:this={sheet}>
|
||||||
|
<header>
|
||||||
|
<span class="label rule">
|
||||||
|
<span class="marker"></span><span class="title">backlinks</span>
|
||||||
|
{workspace.open?.name.trim() || workspace.open?.slug} · linked from {workspace.backlinks.length}
|
||||||
|
</span>
|
||||||
|
<button onclick={() => (workspace.overlay = 'none')}>esc</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
{#each workspace.backlinks as link, index (link.id)}
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
|
||||||
|
<li
|
||||||
|
class:at={index === workspace.backlinksCursor}
|
||||||
|
data-index={index}
|
||||||
|
onpointerenter={() => (workspace.backlinksCursor = index)}
|
||||||
|
onclick={() => openBacklink(link)}
|
||||||
|
>
|
||||||
|
<span class="name">{link.name.trim() || link.slug}</span>
|
||||||
|
<span class="muted">{link.path}</span>
|
||||||
|
{#if link.context}<span class="context">{link.context}</span>{/if}
|
||||||
|
</li>
|
||||||
|
{:else}
|
||||||
|
<li class="muted">nothing links here yet — a link is [text](/n/…) or [[title]].</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<footer class="label">
|
||||||
|
<span class="kbd">j</span><span class="kbd">k</span> move
|
||||||
|
<span class="gap"></span>
|
||||||
|
<span class="kbd">enter</span> open
|
||||||
|
<span class="gap"></span>
|
||||||
|
<span class="kbd">q</span><span class="kbd">esc</span> close
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* A free-standing panel, as the desktop draws one: hull, one rim, the frame's radius. */
|
||||||
|
.sheet {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
max-height: 100%;
|
||||||
|
background: color-mix(in oklab, var(--hull) 97%, transparent);
|
||||||
|
border: 1px solid var(--surface-alt);
|
||||||
|
border-radius: var(--frame-radius);
|
||||||
|
padding: 20px 24px;
|
||||||
|
animation: resolve var(--t-slow) var(--ease-snap) both;
|
||||||
|
width: min(100%, 92ch);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
header { display: flex; align-items: center; gap: 12px; }
|
||||||
|
header .label { flex: 1; }
|
||||||
|
header .marker { background: var(--light); }
|
||||||
|
header .title { color: var(--light); }
|
||||||
|
|
||||||
|
ul { list-style: none; margin: 0; padding: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
|
||||||
|
li {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: var(--radius-m);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color var(--t-base) var(--ease-glide);
|
||||||
|
}
|
||||||
|
li:hover { background: var(--surface); }
|
||||||
|
li.at { background: var(--surface-alt); box-shadow: inset 2px 0 0 var(--light); }
|
||||||
|
|
||||||
|
.name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--fg); font-family: var(--font-sans); font-size: 14px; }
|
||||||
|
.muted { font-size: 11px; }
|
||||||
|
.context { font-size: 12px; color: var(--subtle); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
|
||||||
|
footer { display: flex; align-items: center; gap: 4px; font-size: 10px; }
|
||||||
|
footer .kbd { margin-right: 2px; }
|
||||||
|
footer .gap { width: 14px; }
|
||||||
|
@media (max-width: 767px), (pointer: coarse) {
|
||||||
|
footer { display: none; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
|
|
||||||
const focused = $derived(workspace.tile === tile)
|
const focused = $derived(workspace.tile === tile)
|
||||||
const doc = $derived(tile.open!)
|
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(
|
const words = $derived(
|
||||||
(tile.editing ? tile.draft.body : doc.body).split(/\s+/).filter(Boolean).length,
|
(tile.editing ? tile.draft.body : doc.body).split(/\s+/).filter(Boolean).length,
|
||||||
)
|
)
|
||||||
@@ -161,6 +161,10 @@
|
|||||||
<dd>{relative(doc.updated_at)}</dd>
|
<dd>{relative(doc.updated_at)}</dd>
|
||||||
<dt>words</dt>
|
<dt>words</dt>
|
||||||
<dd>{words}<span class="unit">words</span></dd>
|
<dd>{words}<span class="unit">words</span></dd>
|
||||||
|
{#if doc.backlinks > 0}
|
||||||
|
<dt>linked</dt>
|
||||||
|
<dd><button class="linked" title="what links here (gb)" onclick={() => runCommand('backlinks')}>from {doc.backlinks}</button></dd>
|
||||||
|
{/if}
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
@@ -298,6 +302,9 @@
|
|||||||
}
|
}
|
||||||
.meta dt { color: var(--muted); letter-spacing: 0.18em; text-transform: uppercase; }
|
.meta dt { color: var(--muted); letter-spacing: 0.18em; text-transform: uppercase; }
|
||||||
.meta dd { margin: 0 10px 0 0; color: var(--fg); }
|
.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 { display: flex; gap: 4px; flex-wrap: wrap; flex: none; }
|
||||||
.actions .on { color: var(--light); border-color: var(--light-dim); }
|
.actions .on { color: var(--light); border-color: var(--light-dim); }
|
||||||
|
|||||||
@@ -133,6 +133,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { autocompletion, type CompletionContext, type CompletionResult } from '@codemirror/autocomplete'
|
||||||
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'
|
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'
|
||||||
import { markdown, markdownLanguage } from '@codemirror/lang-markdown'
|
import { markdown, markdownLanguage } from '@codemirror/lang-markdown'
|
||||||
import { HighlightStyle, indentOnInput, syntaxHighlighting } from '@codemirror/language'
|
import { HighlightStyle, indentOnInput, syntaxHighlighting } from '@codemirror/language'
|
||||||
@@ -146,6 +147,7 @@
|
|||||||
} from '@codemirror/view'
|
} from '@codemirror/view'
|
||||||
import { tags } from '@lezer/highlight'
|
import { tags } from '@lezer/highlight'
|
||||||
import { onDestroy, onMount } from 'svelte'
|
import { onDestroy, onMount } from 'svelte'
|
||||||
|
import { api } from './api'
|
||||||
import { hooks } from './commands'
|
import { hooks } from './commands'
|
||||||
import { embedSnippet } from './markdown'
|
import { embedSnippet } from './markdown'
|
||||||
import { workspace, type Tile } from './store.svelte'
|
import { workspace, type Tile } from './store.svelte'
|
||||||
@@ -200,6 +202,16 @@
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
|
'.cm-tooltip': {
|
||||||
|
backgroundColor: 'var(--hull)',
|
||||||
|
color: 'var(--fg)',
|
||||||
|
border: '1px solid var(--rim)',
|
||||||
|
},
|
||||||
|
'.cm-tooltip-autocomplete > ul > li[aria-selected]': {
|
||||||
|
backgroundColor: 'var(--surface-alt)',
|
||||||
|
color: 'var(--light)',
|
||||||
|
},
|
||||||
|
'.cm-completionDetail': { color: 'var(--muted)', fontStyle: 'normal', marginLeft: '1ch' },
|
||||||
'.cm-vim-panel input': {
|
'.cm-vim-panel input': {
|
||||||
fontFamily: 'inherit',
|
fontFamily: 'inherit',
|
||||||
fontSize: 'inherit',
|
fontSize: 'inherit',
|
||||||
@@ -212,6 +224,29 @@
|
|||||||
{ dark: true },
|
{ dark: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
* `[[` offers the titles of documents as the search finds them, each with
|
||||||
|
* where it sits; picking one closes the brackets.
|
||||||
|
*/
|
||||||
|
async function titleCompletions(context: CompletionContext): Promise<CompletionResult | null> {
|
||||||
|
const typed = context.matchBefore(/\[\[[^[\]|\n]*/)
|
||||||
|
if (!typed) return null
|
||||||
|
const query = typed.text.slice(2).trim()
|
||||||
|
if (!query) return null
|
||||||
|
const found = await api.listNodes({ q: query }).catch(() => [])
|
||||||
|
if (context.aborted) return null
|
||||||
|
const options = found
|
||||||
|
.filter((node) => node.kind === 'document' || node.kind === 'file')
|
||||||
|
.filter((node) => node.id !== tile.open?.id)
|
||||||
|
.map((node) => {
|
||||||
|
// What a `[[link]]` can hold: a name with no bracket or bar in it, or else the slug.
|
||||||
|
const name = node.name.trim()
|
||||||
|
const title = name && !/[[\]|\n]/.test(name) ? name : node.slug
|
||||||
|
return { label: title, detail: node.location, apply: `${title}]]` }
|
||||||
|
})
|
||||||
|
return { from: typed.from + 2, options }
|
||||||
|
}
|
||||||
|
|
||||||
/** The `#` lines being written, as `gO` lists them, and the one the cursor is under. */
|
/** The `#` lines being written, as `gO` lists them, and the one the cursor is under. */
|
||||||
function editorHeadings() {
|
function editorHeadings() {
|
||||||
if (!view) return { headings: [], current: 0 }
|
if (!view) return { headings: [], current: 0 }
|
||||||
@@ -264,6 +299,7 @@
|
|||||||
highlightActiveLine(),
|
highlightActiveLine(),
|
||||||
EditorState.allowMultipleSelections.of(true),
|
EditorState.allowMultipleSelections.of(true),
|
||||||
markdown({ base: markdownLanguage, codeLanguages: [] }),
|
markdown({ base: markdownLanguage, codeLanguages: [] }),
|
||||||
|
autocompletion({ override: [titleCompletions], icons: false }),
|
||||||
syntaxHighlighting(markdownHighlight),
|
syntaxHighlighting(markdownHighlight),
|
||||||
EditorView.lineWrapping,
|
EditorView.lineWrapping,
|
||||||
placeholder('write…'),
|
placeholder('write…'),
|
||||||
|
|||||||
@@ -44,6 +44,8 @@
|
|||||||
['x · dd', 'delete what has focus — the row in the list, the document in its pane — into the trash (asks y/N)'],
|
['x · dd', 'delete what has focus — the row in the list, the document in its pane — into the trash (asks y/N)'],
|
||||||
['u', 'undo the last delete: it comes back out of the trash — :trash has the rest'],
|
['u', 'undo the last delete: it comes back out of the trash — :trash has the rest'],
|
||||||
['gh', "the open document's earlier versions, as diffs — Enter brings one back"],
|
['gh', "the open document's earlier versions, as diffs — Enter brings one back"],
|
||||||
|
|
||||||
|
['gb', 'the documents that link to the open one — [[title]] links by title, [[ completes one while writing'],
|
||||||
[':', 'command line — Tab / Shift-Tab cycle the matches, then the argument; ↑ ↓ recall what ran before'],
|
[':', 'command line — Tab / Shift-Tab cycle the matches, then the argument; ↑ ↓ recall what ran before'],
|
||||||
['Ctrl-S', 'save, from anywhere'],
|
['Ctrl-S', 'save, from anywhere'],
|
||||||
['?', 'this screen — in the editor too'],
|
['?', 'this screen — in the editor too'],
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
let article = $state<HTMLElement>()
|
let article = $state<HTMLElement>()
|
||||||
let position = $state('All')
|
let position = $state('All')
|
||||||
// Links into the private workspace come back as their text; see renderMarkdown.
|
// Links into the private workspace come back as their text; see renderMarkdown.
|
||||||
const html = $derived(doc ? renderMarkdown(doc.body, { token }) : '')
|
const html = $derived(doc ? renderMarkdown(doc.body, { share: { token } }) : '')
|
||||||
const title = $derived(doc?.name.trim() ?? '')
|
const title = $derived(doc?.name.trim() ?? '')
|
||||||
const words = $derived(doc ? doc.body.split(/\s+/).filter(Boolean).length : 0)
|
const words = $derived(doc ? doc.body.split(/\s+/).filter(Boolean).length : 0)
|
||||||
// The outline gets a window of its own only when there is something to outline.
|
// The outline gets a window of its own only when there is something to outline.
|
||||||
|
|||||||
@@ -49,6 +49,19 @@ export interface Crumb {
|
|||||||
export interface WorkspaceNode extends NodeSummary {
|
export interface WorkspaceNode extends NodeSummary {
|
||||||
body: string
|
body: string
|
||||||
path: Crumb[]
|
path: Crumb[]
|
||||||
|
/** How many documents link here. */
|
||||||
|
backlinks: number
|
||||||
|
/** Where each `[[title]]` in the body leads, as an /n/ path; a title that leads nowhere is missing. */
|
||||||
|
wikilinks: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A document that links to the one asked about, and the line it does it on. */
|
||||||
|
export interface Backlink {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
slug: string
|
||||||
|
path: string
|
||||||
|
context: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A space as the overview shows it: itself, and what was last written in it. */
|
/** A space as the overview shows it: itself, and what was last written in it. */
|
||||||
@@ -429,6 +442,9 @@ export const api = {
|
|||||||
|
|
||||||
revision: (id: number, rev: number) => request<Revision>(`/nodes/${id}/revisions/${rev}`),
|
revision: (id: number, rev: number) => request<Revision>(`/nodes/${id}/revisions/${rev}`),
|
||||||
|
|
||||||
|
/** The documents that link to this one, the last written first. */
|
||||||
|
backlinks: (id: number) => request<Backlink[]>(`/nodes/${id}/backlinks`),
|
||||||
|
|
||||||
/** Into the trash; `purged` when it was in the trash already and is now gone. */
|
/** Into the trash; `purged` when it was in the trash already and is now gone. */
|
||||||
deleteNode: (id: number) =>
|
deleteNode: (id: number) =>
|
||||||
request<{ ok: true; removed: number; purged: boolean }>(`/nodes/${id}`, { method: 'DELETE' }),
|
request<{ ok: true; removed: number; purged: boolean }>(`/nodes/${id}`, { method: 'DELETE' }),
|
||||||
|
|||||||
+26
-2
@@ -2,7 +2,7 @@ import { tick } from 'svelte'
|
|||||||
import { api, ApiError, isContainer, type BackupLogin, type BackupStatus } from './api'
|
import { api, ApiError, isContainer, type BackupLogin, type BackupStatus } from './api'
|
||||||
import { workspace } from './store.svelte'
|
import { workspace } from './store.svelte'
|
||||||
import { emptyTrash } from './Trash.svelte'
|
import { emptyTrash } from './Trash.svelte'
|
||||||
import { formatDay, parseDay, parseDue, relative, sealedNote, type Heading, type ScrollAmount, type SidewaysAmount } from './util'
|
import { formatDay, parseDay, parseDue, relative, sealedNote, titleHref, type Heading, type ScrollAmount, type SidewaysAmount } from './util'
|
||||||
|
|
||||||
export type { ScrollAmount, SidewaysAmount }
|
export type { ScrollAmount, SidewaysAmount }
|
||||||
|
|
||||||
@@ -76,13 +76,24 @@ const requireOpen = () => {
|
|||||||
return workspace.open
|
return workspace.open
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TITLE_HREF = titleHref('')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Where a link in a document goes. `/n/` links are the workspace's own — the
|
* Where a link in a document goes. `/n/` links are the workspace's own — the
|
||||||
* importer writes them between documents — and are opened in place; anything
|
* importer writes them between documents — and are opened in place, as is a
|
||||||
|
* `[[title]]`, or the offer to write it when no document has it; anything
|
||||||
* else would take the workspace's tab with it, so it gets a tab of its own,
|
* else would take the workspace's tab with it, so it gets a tab of its own,
|
||||||
* as does a workspace link asked for with `gx`.
|
* as does a workspace link asked for with `gx`.
|
||||||
*/
|
*/
|
||||||
export function followLink(href: string, tab = false) {
|
export function followLink(href: string, tab = false) {
|
||||||
|
if (href.startsWith(TITLE_HREF)) {
|
||||||
|
const title = decodeURIComponent(href.slice(TITLE_HREF.length))
|
||||||
|
const path = workspace.open?.wikilinks[title]
|
||||||
|
if (tab) {
|
||||||
|
if (!path) return workspace.say(`no document called "${title}" yet`, 'error')
|
||||||
|
href = `/n/${path}`
|
||||||
|
} else return void workspace.guard(() => workspace.followTitle(title))
|
||||||
|
}
|
||||||
if (href.startsWith('/n/') && !tab) {
|
if (href.startsWith('/n/') && !tab) {
|
||||||
const path = href.slice(3).split('#')[0]
|
const path = href.slice(3).split('#')[0]
|
||||||
return void workspace.guard(() => workspace.openPath(decodeURIComponent(path)))
|
return void workspace.guard(() => workspace.openPath(decodeURIComponent(path)))
|
||||||
@@ -789,6 +800,19 @@ export const commands: CommandSpec[] = [
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'backlinks',
|
||||||
|
aliases: ['bl'],
|
||||||
|
group: 'documents',
|
||||||
|
help: 'the documents that link to the open one — j/k, Enter opens one (gb)',
|
||||||
|
run: async () => {
|
||||||
|
if (!requireOpen()) return
|
||||||
|
await workspace.guard(async () => {
|
||||||
|
await workspace.loadBacklinks()
|
||||||
|
workspace.overlay = 'backlinks'
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'trash',
|
name: 'trash',
|
||||||
args: '[purge]',
|
args: '[purge]',
|
||||||
|
|||||||
+39
-3
@@ -1,6 +1,7 @@
|
|||||||
import DOMPurify from 'dompurify'
|
import DOMPurify from 'dompurify'
|
||||||
import { marked, type TokenizerAndRendererExtension } from 'marked'
|
import { marked, type TokenizerAndRendererExtension } from 'marked'
|
||||||
import { mathEngine } from './math.svelte'
|
import { mathEngine } from './math.svelte'
|
||||||
|
import { titleHref } from './util'
|
||||||
|
|
||||||
marked.setOptions({ gfm: true, breaks: true })
|
marked.setOptions({ gfm: true, breaks: true })
|
||||||
|
|
||||||
@@ -37,8 +38,38 @@ const mathInline: TokenizerAndRendererExtension = {
|
|||||||
renderer: (token) => tex(token.text, token.display),
|
renderer: (token) => tex(token.text, token.display),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* `[[Plan]]` and `[[Plan|the plan]]`: a link to a document by its title. The
|
||||||
|
* server says where each title leads, since it is the one that knows the
|
||||||
|
* titles; one that leads nowhere is drawn dimmed, and following it offers to
|
||||||
|
* write the document.
|
||||||
|
*/
|
||||||
|
const WIKILINK = /^\[\[([^[\]|\n]+)(?:\|([^[\]\n]*))?\]\]/
|
||||||
|
|
||||||
|
/** What the page being rendered says about its `[[titles]]`, for the renderer. */
|
||||||
|
let wikiContext: { leads: Record<string, string>; sharing: boolean } = { leads: {}, sharing: false }
|
||||||
|
|
||||||
|
const wikilink: TokenizerAndRendererExtension = {
|
||||||
|
name: 'wikilink',
|
||||||
|
level: 'inline',
|
||||||
|
start: (src) => src.indexOf('[['),
|
||||||
|
tokenizer(src) {
|
||||||
|
const match = WIKILINK.exec(src)
|
||||||
|
const target = match?.[1].trim()
|
||||||
|
if (match && target) return { type: 'wikilink', raw: match[0], target, label: match[2]?.trim() || target }
|
||||||
|
},
|
||||||
|
renderer: (token) => {
|
||||||
|
const label = escapeHtml(token.label)
|
||||||
|
// The page it leads to is behind the login, as any /n/ link on a share is.
|
||||||
|
if (wikiContext.sharing) return `<span class="private-link" title="a page of the private workspace">${label}</span>`
|
||||||
|
const path = wikiContext.leads[token.target]
|
||||||
|
if (path) return `<a href="/n/${encodeURI(path)}" class="wikilink">${label}</a>`
|
||||||
|
return `<a href="${titleHref(token.target)}" class="wikilink missing" title="not written yet — follow it to write it">${label}</a>`
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
marked.use({
|
marked.use({
|
||||||
extensions: [mathBlock, mathInline],
|
extensions: [mathBlock, mathInline, wikilink],
|
||||||
renderer: {
|
renderer: {
|
||||||
code(token) {
|
code(token) {
|
||||||
return token.lang === 'math' ? tex(token.text, true) : false
|
return token.lang === 'math' ? tex(token.text, true) : false
|
||||||
@@ -84,13 +115,18 @@ function escapeHtml(text: string): string {
|
|||||||
* the cookie reading the document with its key handed out, so the key itself
|
* the cookie reading the document with its key handed out, so the key itself
|
||||||
* never sits in a URL a proxy could log. A link to another document, an
|
* never sits in a URL a proxy could log. A link to another document, an
|
||||||
* address inside the private workspace that would lead the reader to its
|
* address inside the private workspace that would lead the reader to its
|
||||||
* login page, is kept as its text.
|
* login page, is kept as its text. `wikilinks` says where the `[[titles]]`
|
||||||
|
* lead, as the server answered for the document.
|
||||||
*/
|
*/
|
||||||
export function renderMarkdown(source: string, share?: { token: string }): string {
|
export function renderMarkdown(
|
||||||
|
source: string,
|
||||||
|
{ share, wikilinks = {} }: { share?: { token: string }; wikilinks?: Record<string, string> } = {},
|
||||||
|
): string {
|
||||||
const md = share
|
const md = share
|
||||||
? source.replaceAll('/api/media/', `/api/share/${encodeURIComponent(share.token)}/media/`)
|
? source.replaceAll('/api/media/', `/api/share/${encodeURIComponent(share.token)}/media/`)
|
||||||
: source
|
: source
|
||||||
|
|
||||||
|
wikiContext = { leads: wikilinks, sharing: !!share }
|
||||||
const html = marked.parse(md, { async: false }) as string
|
const html = marked.parse(md, { async: false }) as string
|
||||||
if (!share) return DOMPurify.sanitize(html, SANITIZE)
|
if (!share) return DOMPurify.sanitize(html, SANITIZE)
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
unreachable,
|
unreachable,
|
||||||
type Agenda,
|
type Agenda,
|
||||||
type AgendaCard,
|
type AgendaCard,
|
||||||
|
type Backlink,
|
||||||
type Board,
|
type Board,
|
||||||
type Card,
|
type Card,
|
||||||
type Crumb,
|
type Crumb,
|
||||||
@@ -281,7 +282,7 @@ class Workspace {
|
|||||||
pane = $state<Pane>('list')
|
pane = $state<Pane>('list')
|
||||||
flash = $state<Flash>(null)
|
flash = $state<Flash>(null)
|
||||||
|
|
||||||
overlay = $state<'none' | 'help' | 'media' | 'overview' | 'agenda' | 'actions' | 'verify' | 'outline' | 'trash' | 'history'>('none')
|
overlay = $state<'none' | 'help' | 'media' | 'overview' | 'agenda' | 'actions' | 'verify' | 'outline' | 'trash' | 'history' | 'backlinks'>('none')
|
||||||
/** `gO`: the headings of what is on screen, to jump between from the keyboard. */
|
/** `gO`: the headings of what is on screen, to jump between from the keyboard. */
|
||||||
outline = $state.raw<Heading[]>([])
|
outline = $state.raw<Heading[]>([])
|
||||||
outlineCursor = $state(0)
|
outlineCursor = $state(0)
|
||||||
@@ -317,6 +318,9 @@ class Workspace {
|
|||||||
history = $state<RevisionSummary[]>([])
|
history = $state<RevisionSummary[]>([])
|
||||||
historyCursor = $state(0)
|
historyCursor = $state(0)
|
||||||
revision = $state.raw<Revision | null>(null)
|
revision = $state.raw<Revision | null>(null)
|
||||||
|
/** `:backlinks`: the documents that link to the open one. */
|
||||||
|
backlinks = $state<Backlink[]>([])
|
||||||
|
backlinksCursor = $state(0)
|
||||||
overview = $state<SpaceOverview[]>([])
|
overview = $state<SpaceOverview[]>([])
|
||||||
overviewCursor = $state(0)
|
overviewCursor = $state(0)
|
||||||
|
|
||||||
@@ -1096,6 +1100,29 @@ class Workspace {
|
|||||||
this.say(`restored the version from ${formatStamp(revision.saved_at)} — :history has the one it replaced`)
|
this.say(`restored the version from ${formatStamp(revision.saved_at)} — :history has the one it replaced`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Follow a `[[title]]`: to the document it names, or — when there is none
|
||||||
|
* — to a new one called that, here, after asking. Which document a title
|
||||||
|
* names the server says when the text is written, so it is written first.
|
||||||
|
*/
|
||||||
|
async followTitle(title: string) {
|
||||||
|
await this.flushTile()
|
||||||
|
const path = this.open?.wikilinks[title]
|
||||||
|
if (path) return this.openPath(path)
|
||||||
|
if (!(await this.ask(`no document called "${title}" yet — write it in ${this.here}?`))) return
|
||||||
|
await this.createDocument(title)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `:backlinks`: what links to the open document, the last written first. */
|
||||||
|
async loadBacklinks() {
|
||||||
|
const open = this.open
|
||||||
|
if (!open) return
|
||||||
|
// A link typed and not yet written is not one the server knows about.
|
||||||
|
await this.flush()
|
||||||
|
this.backlinks = await api.backlinks(open.id)
|
||||||
|
this.backlinksCursor = 0
|
||||||
|
}
|
||||||
|
|
||||||
async loadTrash() {
|
async loadTrash() {
|
||||||
this.trash = await api.trash()
|
this.trash = await api.trash()
|
||||||
this.trashCursor = Math.min(this.trashCursor, Math.max(this.trash.length - 1, 0))
|
this.trashCursor = Math.min(this.trashCursor, Math.max(this.trash.length - 1, 0))
|
||||||
@@ -1135,10 +1162,28 @@ class Workspace {
|
|||||||
}
|
}
|
||||||
if (this.path.some((crumb) => crumb.id === id)) this.path = saved.path
|
if (this.path.some((crumb) => crumb.id === id)) this.path = saved.path
|
||||||
if (saved.kind === 'space') this.spaces = await api.listSpaces()
|
if (saved.kind === 'space') this.spaces = await api.listSpaces()
|
||||||
|
await this.reread()
|
||||||
await this.refresh()
|
await this.refresh()
|
||||||
this.say(`renamed to ${saved.slug}`)
|
this.say(`renamed to ${saved.slug}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the open documents again after a rename or a move: their paths may
|
||||||
|
* have changed with it, and their text too, where the server rewrote a
|
||||||
|
* link to what moved. One being written is left as it is — saving it is
|
||||||
|
* then refused, and `:e!` takes the rewritten text.
|
||||||
|
*/
|
||||||
|
async reread() {
|
||||||
|
for (const tile of this.tiles) {
|
||||||
|
if (!tile.open || tile.editing || tile.dirty) continue
|
||||||
|
const node = await api.getNode(tile.open.id).catch(() => null)
|
||||||
|
if (!node) continue
|
||||||
|
tile.open = node
|
||||||
|
tile.draft = { name: node.name, body: node.body, created_at: node.created_at }
|
||||||
|
}
|
||||||
|
if (this.open) this.#syncUrl(this.open.path)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete a space and everything in it. The app has to land somewhere
|
* Delete a space and everything in it. The app has to land somewhere
|
||||||
* afterwards, so it falls into the next space — or, if that was the last
|
* afterwards, so it falls into the next space — or, if that was the last
|
||||||
@@ -1168,6 +1213,7 @@ class Workspace {
|
|||||||
/** Reparent the node under the cursor, for `:mv`. */
|
/** Reparent the node under the cursor, for `:mv`. */
|
||||||
async moveNode(id: number, parentId: number) {
|
async moveNode(id: number, parentId: number) {
|
||||||
await api.moveNode(id, parentId)
|
await api.moveNode(id, parentId)
|
||||||
|
await this.reread()
|
||||||
await this.refresh()
|
await this.refresh()
|
||||||
this.say(`moved #${id}`)
|
this.say(`moved #${id}`)
|
||||||
}
|
}
|
||||||
@@ -1194,6 +1240,7 @@ class Workspace {
|
|||||||
)
|
)
|
||||||
const gone = new Set(nodes.map((n) => n.id))
|
const gone = new Set(nodes.map((n) => n.id))
|
||||||
this.marked = this.marked.filter((n) => !gone.has(n.id))
|
this.marked = this.marked.filter((n) => !gone.has(n.id))
|
||||||
|
if (moved > 0) await this.reread()
|
||||||
await this.refresh(nodes.length === 1 ? nodes[0].id : undefined)
|
await this.refresh(nodes.length === 1 ? nodes[0].id : undefined)
|
||||||
const what = nodes.length === 1 ? nodes[0].name.trim() || nodes[0].slug : `${moved}`
|
const what = nodes.length === 1 ? nodes[0].name.trim() || nodes[0].slug : `${moved}`
|
||||||
const already = unchanged > 0 ? ` — ${unchanged} already ${unchanged === 1 ? 'was' : 'were'}` : ''
|
const already = unchanged > 0 ? ` — ${unchanged} already ${unchanged === 1 ? 'was' : 'were'}` : ''
|
||||||
|
|||||||
+13
-4
@@ -234,16 +234,19 @@ export function sourceHeadings(source: string): { text: string; level: number; l
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The link the cursor stands on in a line of markdown — `[text](href)`, an
|
* The link the cursor stands on in a line of markdown — `[text](href)`, an
|
||||||
* embed's ``, `<https://…>` or a bare address — or else the first
|
* embed's ``, `[[title]]`, `<https://…>` or a bare address — or else the first
|
||||||
* one after it on the line, as vim's `gf` looks ahead to the next name.
|
* one after it on the line, as vim's `gf` looks ahead to the next name.
|
||||||
*/
|
*/
|
||||||
export function linkAt(line: string, column: number): string | null {
|
export function linkAt(line: string, column: number): string | null {
|
||||||
const pattern =
|
const pattern =
|
||||||
/!?\[[^\]]*\]\(\s*<?([^)\s>]+)>?(?:\s+"[^"]*")?\s*\)|<((?:https?:\/\/|\/n\/)[^>\s]+)>|((?:https?:\/\/|\/n\/)[^\s<>()]+)/g
|
/\[\[([^[\]|\n]+)(?:\|[^[\]\n]*)?\]\]|!?\[[^\]]*\]\(\s*<?([^)\s>]+)>?(?:\s+"[^"]*")?\s*\)|<((?:https?:\/\/|\/n\/)[^>\s]+)>|((?:https?:\/\/|\/n\/)[^\s<>()]+)/g
|
||||||
let ahead: string | null = null
|
let ahead: string | null = null
|
||||||
for (const match of line.matchAll(pattern)) {
|
for (const match of line.matchAll(pattern)) {
|
||||||
// A sentence that ends on an address ends with a full stop, not the address.
|
// A sentence that ends on a bare address ends with a full stop, not the address.
|
||||||
const href = match[1] ?? match[2] ?? match[3].replace(/[.,;:!?]+$/, '')
|
const href = match[1]?.trim()
|
||||||
|
? titleHref(match[1].trim())
|
||||||
|
: (match[2] ?? match[3] ?? match[4]?.replace(/[.,;:!?]+$/, ''))
|
||||||
|
if (!href) continue
|
||||||
const start = match.index
|
const start = match.index
|
||||||
if (column >= start && column < start + match[0].length) return href
|
if (column >= start && column < start + match[0].length) return href
|
||||||
if (start >= column) ahead ??= href
|
if (start >= column) ahead ??= href
|
||||||
@@ -251,6 +254,12 @@ export function linkAt(line: string, column: number): string | null {
|
|||||||
return ahead
|
return ahead
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
export const titleHref = (title: string) => `/n/?title=${encodeURIComponent(title)}`
|
||||||
|
|
||||||
/** What a share link's reader will miss: a share never serves a sealed file. */
|
/** What a share link's reader will miss: a share never serves a sealed file. */
|
||||||
export const sealedNote = (sealed: number) =>
|
export const sealedNote = (sealed: number) =>
|
||||||
sealed > 0 ? ` — ${sealed} sealed file${sealed === 1 ? '' : 's'} in it stay hidden from readers` : ''
|
sealed > 0 ? ` — ${sealed} sealed file${sealed === 1 ? '' : 's'} in it stay hidden from readers` : ''
|
||||||
|
|||||||
Reference in new issue
Block a user