mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-04 17:07:00 +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
@@ -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 doc = $derived(tile.open!)
|
||||
const html = $derived(renderMarkdown(tile.editing ? tile.draft.body : doc.body))
|
||||
const html = $derived(renderMarkdown(tile.editing ? tile.draft.body : doc.body, { wikilinks: doc.wikilinks }))
|
||||
const words = $derived(
|
||||
(tile.editing ? tile.draft.body : doc.body).split(/\s+/).filter(Boolean).length,
|
||||
)
|
||||
@@ -161,6 +161,10 @@
|
||||
<dd>{relative(doc.updated_at)}</dd>
|
||||
<dt>words</dt>
|
||||
<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>
|
||||
|
||||
<div class="actions">
|
||||
@@ -298,6 +302,9 @@
|
||||
}
|
||||
.meta dt { color: var(--muted); letter-spacing: 0.18em; text-transform: uppercase; }
|
||||
.meta dd { margin: 0 10px 0 0; color: var(--fg); }
|
||||
/* Read as the rest of the line, and still a way to the backlinks. */
|
||||
.meta .linked { all: unset; cursor: pointer; text-decoration: underline; text-decoration-color: var(--overlay); text-underline-offset: 4px; }
|
||||
.meta .linked:hover, .meta .linked:focus-visible { color: var(--light); }
|
||||
|
||||
.actions { display: flex; gap: 4px; flex-wrap: wrap; flex: none; }
|
||||
.actions .on { color: var(--light); border-color: var(--light-dim); }
|
||||
|
||||
@@ -133,6 +133,7 @@
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { autocompletion, type CompletionContext, type CompletionResult } from '@codemirror/autocomplete'
|
||||
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'
|
||||
import { markdown, markdownLanguage } from '@codemirror/lang-markdown'
|
||||
import { HighlightStyle, indentOnInput, syntaxHighlighting } from '@codemirror/language'
|
||||
@@ -146,6 +147,7 @@
|
||||
} from '@codemirror/view'
|
||||
import { tags } from '@lezer/highlight'
|
||||
import { onDestroy, onMount } from 'svelte'
|
||||
import { api } from './api'
|
||||
import { hooks } from './commands'
|
||||
import { embedSnippet } from './markdown'
|
||||
import { workspace, type Tile } from './store.svelte'
|
||||
@@ -200,6 +202,16 @@
|
||||
display: 'flex',
|
||||
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': {
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 'inherit',
|
||||
@@ -212,6 +224,29 @@
|
||||
{ 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. */
|
||||
function editorHeadings() {
|
||||
if (!view) return { headings: [], current: 0 }
|
||||
@@ -264,6 +299,7 @@
|
||||
highlightActiveLine(),
|
||||
EditorState.allowMultipleSelections.of(true),
|
||||
markdown({ base: markdownLanguage, codeLanguages: [] }),
|
||||
autocompletion({ override: [titleCompletions], icons: false }),
|
||||
syntaxHighlighting(markdownHighlight),
|
||||
EditorView.lineWrapping,
|
||||
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)'],
|
||||
['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"],
|
||||
|
||||
['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'],
|
||||
['Ctrl-S', 'save, from anywhere'],
|
||||
['?', 'this screen — in the editor too'],
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
let article = $state<HTMLElement>()
|
||||
let position = $state('All')
|
||||
// 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 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.
|
||||
|
||||
@@ -49,6 +49,19 @@ export interface Crumb {
|
||||
export interface WorkspaceNode extends NodeSummary {
|
||||
body: string
|
||||
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. */
|
||||
@@ -429,6 +442,9 @@ export const api = {
|
||||
|
||||
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. */
|
||||
deleteNode: (id: number) =>
|
||||
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 { 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]',
|
||||
|
||||
+39
-3
@@ -1,6 +1,7 @@
|
||||
import DOMPurify from 'dompurify'
|
||||
import { marked, type TokenizerAndRendererExtension } from 'marked'
|
||||
import { mathEngine } from './math.svelte'
|
||||
import { titleHref } from './util'
|
||||
|
||||
marked.setOptions({ gfm: true, breaks: true })
|
||||
|
||||
@@ -37,8 +38,38 @@ const mathInline: TokenizerAndRendererExtension = {
|
||||
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({
|
||||
extensions: [mathBlock, mathInline],
|
||||
extensions: [mathBlock, mathInline, wikilink],
|
||||
renderer: {
|
||||
code(token) {
|
||||
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
|
||||
* 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
|
||||
* 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
|
||||
? source.replaceAll('/api/media/', `/api/share/${encodeURIComponent(share.token)}/media/`)
|
||||
: source
|
||||
|
||||
wikiContext = { leads: wikilinks, sharing: !!share }
|
||||
const html = marked.parse(md, { async: false }) as string
|
||||
if (!share) return DOMPurify.sanitize(html, SANITIZE)
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
unreachable,
|
||||
type Agenda,
|
||||
type AgendaCard,
|
||||
type Backlink,
|
||||
type Board,
|
||||
type Card,
|
||||
type Crumb,
|
||||
@@ -281,7 +282,7 @@ class Workspace {
|
||||
pane = $state<Pane>('list')
|
||||
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. */
|
||||
outline = $state.raw<Heading[]>([])
|
||||
outlineCursor = $state(0)
|
||||
@@ -317,6 +318,9 @@ class Workspace {
|
||||
history = $state<RevisionSummary[]>([])
|
||||
historyCursor = $state(0)
|
||||
revision = $state.raw<Revision | null>(null)
|
||||
/** `:backlinks`: the documents that link to the open one. */
|
||||
backlinks = $state<Backlink[]>([])
|
||||
backlinksCursor = $state(0)
|
||||
overview = $state<SpaceOverview[]>([])
|
||||
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`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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() {
|
||||
this.trash = await api.trash()
|
||||
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 (saved.kind === 'space') this.spaces = await api.listSpaces()
|
||||
await this.reread()
|
||||
await this.refresh()
|
||||
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
|
||||
* 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`. */
|
||||
async moveNode(id: number, parentId: number) {
|
||||
await api.moveNode(id, parentId)
|
||||
await this.reread()
|
||||
await this.refresh()
|
||||
this.say(`moved #${id}`)
|
||||
}
|
||||
@@ -1194,6 +1240,7 @@ class Workspace {
|
||||
)
|
||||
const gone = new Set(nodes.map((n) => 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)
|
||||
const what = nodes.length === 1 ? nodes[0].name.trim() || nodes[0].slug : `${moved}`
|
||||
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
|
||||
* 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.
|
||||
*/
|
||||
export function linkAt(line: string, column: number): string | null {
|
||||
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
|
||||
for (const match of line.matchAll(pattern)) {
|
||||
// A sentence that ends on an address ends with a full stop, not the address.
|
||||
const href = match[1] ?? match[2] ?? match[3].replace(/[.,;:!?]+$/, '')
|
||||
// A sentence that ends on a bare address ends with a full stop, not the address.
|
||||
const href = match[1]?.trim()
|
||||
? titleHref(match[1].trim())
|
||||
: (match[2] ?? match[3] ?? match[4]?.replace(/[.,;:!?]+$/, ''))
|
||||
if (!href) continue
|
||||
const start = match.index
|
||||
if (column >= start && column < start + match[0].length) return href
|
||||
if (start >= column) ahead ??= href
|
||||
@@ -251,6 +254,12 @@ export function linkAt(line: string, column: number): string | null {
|
||||
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. */
|
||||
export const sealedNote = (sealed: number) =>
|
||||
sealed > 0 ? ` — ${sealed} sealed file${sealed === 1 ? '' : 's'} in it stay hidden from readers` : ''
|
||||
|
||||
Reference in new issue
Block a user