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:
nvrl committed 2026-10-20 20:20:40 +02:00
1 parent fc2be66b0c
commit a42f9074a2
23 files changed
+1281 -35

No files matched your search

+26 -2
View File
@@ -2,7 +2,7 @@ import { tick } from 'svelte'
import { api, ApiError, isContainer, type BackupLogin, type BackupStatus } from './api'
import { workspace } from './store.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 }
@@ -76,13 +76,24 @@ const requireOpen = () => {
return workspace.open
}
const TITLE_HREF = titleHref('')
/**
* 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,
* as does a workspace link asked for with `gx`.
*/
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) {
const path = href.slice(3).split('#')[0]
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',
args: '[purge]',