From ea12d3cb9be15b0ca17df69209e1507bf4010141 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 15 Sep 2026 18:16:22 -0500 Subject: [PATCH 1/6] Fix(editor): keep the wikilink picker closed for [[ inside code (#783) The [[ picker matched the last "[[" on the line without checking whether those brackets sat inside a code span or code block. With Auto-close Markdown on, typing "[[" inside backticks and deleting the auto-inserted "]]" left an open "[[" in the code span, and every keystroke after the closing backtick re-opened the picker for it. The [[, [[Note# and [[Note^ sources now resolve the syntax tree at the opening brackets and stand down inside InlineCode, FencedCode and CodeBlock, matching the renderer's rule for what counts as literal text (#248). A fresh [[ typed in prose afterwards opens the picker as before. Closes #783 --- .../app-core/src/lib/cm-wikilinks.test.ts | 47 +++++++++++++++++++ packages/app-core/src/lib/cm-wikilinks.ts | 22 +++++++++ .../src}/asset-path-resolution.ts | 0 3 files changed, 69 insertions(+) rename packages/{app-core/src/lib => shared-domain/src}/asset-path-resolution.ts (100%) diff --git a/packages/app-core/src/lib/cm-wikilinks.test.ts b/packages/app-core/src/lib/cm-wikilinks.test.ts index 55a17a4e..312c07b7 100644 --- a/packages/app-core/src/lib/cm-wikilinks.test.ts +++ b/packages/app-core/src/lib/cm-wikilinks.test.ts @@ -3,6 +3,7 @@ import { CompletionContext } from '@codemirror/autocomplete' import { EditorState } from '@codemirror/state' import { EditorView } from '@codemirror/view' +import { markdown, markdownLanguage } from '@codemirror/lang-markdown' import { describe, expect, it, vi } from 'vitest' import { wikilinkSource, @@ -361,3 +362,49 @@ describe('editing an existing link keeps its section and alias (#686)', () => { done() }) }) + +describe('wikilink pickers stay closed for `[[` inside code (#783)', () => { + // The markdown grammar is what tells a code span apart from prose; the other + // suites run without it, so their `[[` always counts as prose. + function markdownContext(doc: string, pos = doc.length): CompletionContext { + const state = EditorState.create({ + doc, + extensions: [markdown({ base: markdownLanguage, addKeymap: false })] + }) + return new CompletionContext(state, pos, true) + } + + it('still completes a wikilink typed in prose', () => { + const result = wikilinkSource(markdownContext('[[zen')) + expect(result?.options.map((option) => option.label)).toEqual( + expect.arrayContaining(['Zen Garden']) + ) + }) + + it('does not open while typing `[[` inside an inline code span', () => { + expect(wikilinkSource(markdownContext('`[[zen`', 6))).toBeNull() + }) + + it('stays closed after the caret leaves a code span holding an unclosed `[[`', () => { + // Auto-close left `[[` open inside the backticks; typing after the closing + // backtick used to re-open the picker on every keystroke. + expect(wikilinkSource(markdownContext('`[[` and then prose'))).toBeNull() + expect(wikilinkSource(markdownContext('Use `[[` to link, then type zen'))).toBeNull() + }) + + it('does not open for `[[` inside a fenced code block', () => { + expect(wikilinkSource(markdownContext('```\n[[zen\n```\n', 9))).toBeNull() + }) + + it('keeps the heading and block pickers out of code spans too', async () => { + expect(await wikilinkHeadingSource(markdownContext('`[[Zen Garden#` more'))).toBeNull() + expect(await wikilinkBlockSource(markdownContext('`[[Zen Garden^` more'))).toBeNull() + }) + + it('opens again for a fresh `[[` typed after the code span', () => { + const result = wikilinkSource(markdownContext('`[[` then [[zen')) + expect(result?.options.map((option) => option.label)).toEqual( + expect.arrayContaining(['Zen Garden']) + ) + }) +}) diff --git a/packages/app-core/src/lib/cm-wikilinks.ts b/packages/app-core/src/lib/cm-wikilinks.ts index ad055471..aabcd7e0 100644 --- a/packages/app-core/src/lib/cm-wikilinks.ts +++ b/packages/app-core/src/lib/cm-wikilinks.ts @@ -1,4 +1,5 @@ import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete' +import { syntaxTree } from '@codemirror/language' import type { EditorState, TransactionSpec } from '@codemirror/state' import type { EditorView } from '@codemirror/view' import { useStore } from '../store' @@ -15,6 +16,25 @@ function normalize(value: string): string { return value.trim().toLowerCase() } +/** + * True when `pos` sits inside a code span or code block. `[[` there is literal + * text (the renderer leaves it raw for the same reason, #248), so no picker + * should open for it. The check is anchored on the `[[` itself, not the caret: + * with Auto-close Markdown on, typing `[[` inside `` `…` `` and deleting the + * auto-inserted `]]` left an open `[[` in the code span, and every keystroke + * after the closing backtick re-opened the picker for it (#783). + */ +function isInsideCode(state: EditorState, pos: number): boolean { + let node = syntaxTree(state).resolveInner(pos, 1) + while (node) { + const n = node.name + if (n === 'FencedCode' || n === 'CodeBlock' || n === 'InlineCode') return true + if (!node.parent) break + node = node.parent + } + return false +} + /** * The rest of the wikilink the caret sits in, when that link is already closed * on this line: the text between the caret and its `]]`. Null while the link is @@ -74,6 +94,7 @@ function wikilinkMatch(context: CompletionContext): { const before = state.doc.sliceString(line.from, pos) const openIndex = before.lastIndexOf('[[') if (openIndex < 0) return null + if (isInsideCode(state, line.from + openIndex + 1)) return null const inside = before.slice(openIndex + 2) if (inside.includes(']]')) return null @@ -231,6 +252,7 @@ function wikilinkAnchorMatch( const before = state.doc.sliceString(line.from, pos) const openIndex = before.lastIndexOf('[[') if (openIndex < 0) return null + if (isInsideCode(state, line.from + openIndex + 1)) return null const inside = before.slice(openIndex + 2) if (inside.includes(']]') || inside.includes('|')) return null diff --git a/packages/app-core/src/lib/asset-path-resolution.ts b/packages/shared-domain/src/asset-path-resolution.ts similarity index 100% rename from packages/app-core/src/lib/asset-path-resolution.ts rename to packages/shared-domain/src/asset-path-resolution.ts From e167302db0bf72e0a7cfb97418b4428c40f8fc6e Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 15 Sep 2026 18:16:22 -0500 Subject: [PATCH 2/6] Fix(tasks): keep forwarded and cancelled records off every Kanban board (#786) A task forwarded Note 1 -> Note 2 -> Note 3 leaves a "[>]" record in each note it passed through. The Status board (via groupTasks) and the custom @field boards already left those records out, but the Priority and Folder boards only skipped done cards, so the same task showed as three cards, and "[-]" cancelled tasks leaked onto those two boards the same way. One rule now decides what is a card on any board: open work only, so "[x]", "[>]" and "[-]" all stay off. The forwarded record is the trail a moved task leaves behind, already a closed state; the live copy in the destination note is the one card. The Tasks list keeps its separate Forwarded group. Closes #786 --- .../src/components/TasksKanban.test.ts | 41 +++++++++++++++++++ .../app-core/src/components/TasksKanban.tsx | 19 +++++++-- 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/packages/app-core/src/components/TasksKanban.test.ts b/packages/app-core/src/components/TasksKanban.test.ts index 42cba323..5e75cd4b 100644 --- a/packages/app-core/src/components/TasksKanban.test.ts +++ b/packages/app-core/src/components/TasksKanban.test.ts @@ -10,6 +10,7 @@ import { kanbanGroupByKeyPlan, kanbanPendingGroupByPlan, NO_VALUE_COLUMN_ID, + priorityColumns, statusColumns, taskIdentityKey, type Column, @@ -457,3 +458,43 @@ describe('folder board (#730)', () => { ]) }) }) + +describe('forwarded and cancelled records stay off every board (#786)', () => { + // One task forwarded Note 1 → Note 2 → Note 3: two `[>]` trail records and + // one live copy. Plus a cancelled and a done task for the other closed states. + const chain: VaultTask[] = [ + card({ content: 'Write the report', sourcePath: 'inbox/Note 1.md', forwarded: true, priority: 'high' }), + card({ content: 'Write the report', sourcePath: 'inbox/Note 2.md', forwarded: true, priority: 'high' }), + card({ content: 'Write the report', sourcePath: 'inbox/Note 3.md', priority: 'high' }), + card({ content: 'Old idea', sourcePath: 'inbox/Note 3.md', taskIndex: 1, cancelled: true }), + card({ content: 'Shipped', sourcePath: 'inbox/Note 3.md', taskIndex: 2, checked: true }), + card({ content: 'Still open', sourcePath: 'inbox/Note 3.md', taskIndex: 3 }) + ].map((task) => ({ ...task, noteFolder: 'inbox' as const })) + + const cards = (columns: Column[]): string[] => + columns.flatMap((c) => c.tasks.map((t) => `${t.sourcePath} › ${t.content}`)) + + it('shows the forwarded task once on the Priority board, from the note it lives in now', () => { + const columns = priorityColumns(chain) + expect(columnIds(columns, 'high')).toEqual(['Write the report']) + expect(cards(columns)).toEqual(['inbox/Note 3.md › Write the report', 'inbox/Note 3.md › Still open']) + }) + + it('shows it once on the Folder board too', () => { + const columns = folderColumns(chain, false, { + folderRoot: '', + systemFolderPaths: null, + systemFolderLabels: null + }) + expect(cards(columns)).toEqual(['inbox/Note 3.md › Write the report', 'inbox/Note 3.md › Still open']) + }) + + it('and the Status board keeps leaving the trail records out', () => { + const columns = statusColumns(chain, TODAY) + expect(cards(columns)).toEqual([ + 'inbox/Note 3.md › Write the report', + 'inbox/Note 3.md › Still open', + 'inbox/Note 3.md › Shipped' + ]) + }) +}) diff --git a/packages/app-core/src/components/TasksKanban.tsx b/packages/app-core/src/components/TasksKanban.tsx index fcdb1ce0..37d9f3d2 100644 --- a/packages/app-core/src/components/TasksKanban.tsx +++ b/packages/app-core/src/components/TasksKanban.tsx @@ -217,13 +217,24 @@ export function completeStatusOrder(saved: string[], builtIds: string[]): string return result } -function priorityColumns(tasks: VaultTask[]): Column[] { +/** A board shows work that is still live. Besides done cards, the two other + * closed states stay off it: a `[>]` forwarded record is the trail a task left + * behind when it moved to another note (its live copy is the card), and a + * `[-]` cancelled task was abandoned on purpose. The Status board drops both + * through `groupTasks`; the field boards skip them explicitly; these two + * boards only skipped done cards, so a task forwarded across three notes read + * as three cards (#786). */ +function isBoardCard(task: VaultTask): boolean { + return !task.checked && !task.forwarded && !task.cancelled +} + +export function priorityColumns(tasks: VaultTask[]): Column[] { const high: VaultTask[] = [] const med: VaultTask[] = [] const low: VaultTask[] = [] const none: VaultTask[] = [] for (const task of tasks) { - if (task.checked) continue + if (!isBoardCard(task)) continue if (task.priority === 'high') high.push(task) else if (task.priority === 'med') med.push(task) else if (task.priority === 'low') low.push(task) @@ -313,7 +324,7 @@ export function folderColumns( else byId.set(id, { label, folder, dir, tasks: [task] }) } for (const task of tasks) { - if (task.checked) continue + if (!isBoardCard(task)) continue if (task.noteFolder === 'archive' && !showArchived) continue const location = noteLocationOf(task, layout.systemFolderPaths) const vaultDir = [location.prefix, location.dir].filter(Boolean).join('/') @@ -391,7 +402,7 @@ function fieldColumns(tasks: VaultTask[], fieldKey: string, order: string[]): Co const byValue = new Map() const noValue: VaultTask[] = [] for (const task of tasks) { - if (task.checked || task.forwarded || task.cancelled) continue + if (!isBoardCard(task)) continue const value = task.fields?.[fieldKey] if (value) { const list = byValue.get(value) From 99f420405d3a796bcc44d1ffd329e3c2488ff6b8 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 15 Sep 2026 18:16:22 -0500 Subject: [PATCH 3/6] Feat(assets): rewrite references when an asset is renamed or moved (#785) Renaming a note already rewrote its inbound wikilinks; renaming or moving an asset left every "![[assets/old.png]]" pointing at a file that no longer existed. The renderer's asset resolver moves to shared-domain and now reports which reading found a reference (note-relative, vault-root, or unique basename). A new rewriter walks a note body for "![[embed]]" / "[[link]]" wikilinks and "![](href)" / "[](href)" destinations, resolves each the way the renderer does, and re-targets the ones that pointed at the asset in the author's own style: a note-relative href stays relative, a vault-root path stays rooted (keeping a spelled-out leading slash), a bare name stays bare while it still names exactly one asset and otherwise becomes the full path, and a wikilink never gains ".." segments. Aliases, size hints, fragments, queries, percent-encoding, angle brackets and link titles survive; code is skipped. Desktop renameAsset/moveAsset and the Go server's RenameAsset/MoveAsset snapshot the asset and note lists before the file operation and rewrite only the notes that referenced the file (hasAttachments, assetEmbeds, or a file-shaped wikilink that resolves to it). The renderer gains renameAsset/moveAsset store actions that flush dirty buffers first and re-list assets and notes after, used by the sidebar menu and drag-onto- folder, the note list, the Assets view and the reading view's image menu. Closes #785 --- apps/desktop/src/main/vault.test.ts | 78 ++++ apps/desktop/src/main/vault.ts | 70 ++- .../internal/vault/asset_link_rename.go | 401 ++++++++++++++++++ apps/server/internal/vault/asset_ops_test.go | 200 +++++++++ apps/server/internal/vault/vault.go | 80 +++- .../app-core/src/components/AssetsView.tsx | 4 +- packages/app-core/src/components/NoteList.tsx | 8 +- packages/app-core/src/components/Preview.tsx | 8 +- packages/app-core/src/components/Sidebar.tsx | 11 +- .../app-core/src/lib/asset-path-resolution.ts | 4 + packages/app-core/src/lib/help.ts | 2 +- packages/app-core/src/store.ts | 33 ++ .../src/asset-link-rename.test.ts | 165 +++++++ .../shared-domain/src/asset-link-rename.ts | 175 ++++++++ .../src/asset-path-resolution.ts | 41 +- 15 files changed, 1237 insertions(+), 43 deletions(-) create mode 100644 apps/server/internal/vault/asset_link_rename.go create mode 100644 packages/app-core/src/lib/asset-path-resolution.ts create mode 100644 packages/shared-domain/src/asset-link-rename.test.ts create mode 100644 packages/shared-domain/src/asset-link-rename.ts diff --git a/apps/desktop/src/main/vault.test.ts b/apps/desktop/src/main/vault.test.ts index 73211d7e..a93c78c7 100644 --- a/apps/desktop/src/main/vault.test.ts +++ b/apps/desktop/src/main/vault.test.ts @@ -558,6 +558,84 @@ describe('deleteAsset', () => { await expect(readFile(path.join(root, duplicated.path), 'utf8')).resolves.toBe('image-bytes') }) + it('rewrites every reference to a renamed asset, in the shapes people write them (#785)', async () => { + const root = await makeTempDir('zennotes-asset-rename-links-') + await ensureVaultLayout(root) + await mkdir(path.join(root, 'assets'), { recursive: true }) + await writeFile(path.join(root, 'assets', 'shot.png'), 'png', 'utf8') + await writeFile(path.join(root, 'assets', 'other.png'), 'png', 'utf8') + const embeds = [ + '# Embeds', + '', + '![[assets/shot.png]]', + '![[assets/shot.png|300]]', + '![alt](assets/shot.png "Shot")', + '[[assets/shot.png|open it]]', + '[page two](/assets/shot.png#page=2)', + '`![[assets/shot.png]]` stays literal', + '![[assets/other.png]]', + '' + ] + await writeFile(path.join(root, 'inbox', 'Embeds.md'), embeds.join('\n'), 'utf8') + // Bare basenames resolve while unique in the vault; a plain file link is + // neither an embed nor a note wikilink, so it rides on `hasAttachments`. + await writeFile( + path.join(root, 'inbox', 'Bare.md'), + 'See ![[shot.png]] and [the file](shot.png).\n', + 'utf8' + ) + await writeFile(path.join(root, 'inbox', 'Unrelated.md'), 'Nothing here, just [[Embeds]].\n', 'utf8') + const untouchedBefore = await stat(path.join(root, 'inbox', 'Unrelated.md')) + + const renamed = await renameAsset(root, 'assets/shot.png', 'screenshot.png') + expect(renamed.path).toBe('assets/screenshot.png') + + await expect(readFile(path.join(root, 'inbox', 'Embeds.md'), 'utf8')).resolves.toBe( + embeds + .join('\n') + .replace(/assets\/shot\.png/g, 'assets/screenshot.png') + .replace('`![[assets/screenshot.png]]`', '`![[assets/shot.png]]`') + ) + await expect(readFile(path.join(root, 'inbox', 'Bare.md'), 'utf8')).resolves.toBe( + 'See ![[screenshot.png]] and [the file](screenshot.png).\n' + ) + const untouchedAfter = await stat(path.join(root, 'inbox', 'Unrelated.md')) + expect(untouchedAfter.mtimeMs).toBe(untouchedBefore.mtimeMs) + }) + + it('re-targets references when an asset moves to another folder, in the author\'s style (#785)', async () => { + const root = await makeTempDir('zennotes-asset-move-links-') + await ensureVaultLayout(root) + await mkdir(path.join(root, 'assets'), { recursive: true }) + await mkdir(path.join(root, 'inbox', 'Daily'), { recursive: true }) + await writeFile(path.join(root, 'assets', 'shot.png'), 'png', 'utf8') + await writeFile( + path.join(root, 'inbox', 'Rooted.md'), + '# Rooted\n\n![[assets/shot.png|300]]\n[page](/assets/shot.png#page=2)\n', + 'utf8' + ) + await writeFile( + path.join(root, 'inbox', 'Daily', '2026-09-15.md'), + '# Daily\n\n![shot](../../assets/shot.png "Shot")\n', + 'utf8' + ) + // A bare name keeps resolving by basename after the move, so it is left as written. + await writeFile(path.join(root, 'inbox', 'Bare.md'), 'See ![[shot.png]] and [the file](shot.png).\n', 'utf8') + const bareBefore = await stat(path.join(root, 'inbox', 'Bare.md')) + + const moved = await moveAsset(root, 'assets/shot.png', 'media/screenshots') + expect(moved.path).toBe('media/screenshots/shot.png') + + await expect(readFile(path.join(root, 'inbox', 'Rooted.md'), 'utf8')).resolves.toBe( + '# Rooted\n\n![[media/screenshots/shot.png|300]]\n[page](/media/screenshots/shot.png#page=2)\n' + ) + await expect(readFile(path.join(root, 'inbox', 'Daily', '2026-09-15.md'), 'utf8')).resolves.toBe( + '# Daily\n\n![shot](../../media/screenshots/shot.png "Shot")\n' + ) + const bareAfter = await stat(path.join(root, 'inbox', 'Bare.md')) + expect(bareAfter.mtimeMs).toBe(bareBefore.mtimeMs) + }) + it('removes a non-markdown asset inside the vault and can restore it', async () => { const root = await makeTempDir('zennotes-delete-asset-') await ensureVaultLayout(root) diff --git a/apps/desktop/src/main/vault.ts b/apps/desktop/src/main/vault.ts index 47832097..15e84e0d 100644 --- a/apps/desktop/src/main/vault.ts +++ b/apps/desktop/src/main/vault.ts @@ -55,6 +55,8 @@ import { import { DEMO_TOUR_DIR } from '@shared/demo-tour' import { normalizeNoteComments } from '@shared/note-comments' import { FRONTMATTER_BLOCK_RE, frontmatterTags } from '@shared/frontmatter' +import { rewriteAssetReferences } from '@shared/asset-link-rename' +import { resolveAssetPathAmong } from '@shared/asset-path-resolution' import { IMAGE_FILE_EXTENSIONS, pastedImageFilename } from '@shared/pasted-image' import { DATABASE_SIDECAR_SUFFIX, @@ -3705,7 +3707,14 @@ export async function renameAsset( const source = await assertAssetFile(root, rel) const cleanName = cleanAssetFilename(nextName) const destAbs = path.join(path.dirname(source.abs), cleanName) - if (destAbs !== source.abs) { + const willRename = destAbs !== source.abs + // Snapshot the vault before the move so references still resolve to the + // asset under its current name; they are rewritten afterwards, the way a + // note rename handles its inbound wikilinks (#785). + const [assetsBefore, notesBefore] = willRename + ? await Promise.all([listAssets(root), listNotes(root)]) + : [[], []] + if (willRename) { try { await fs.access(destAbs) const [srcStat, dstStat] = await Promise.all([fs.stat(source.abs), fs.stat(destAbs)]) @@ -3723,7 +3732,55 @@ export async function renameAsset( await fs.rename(source.abs, destAbs) } } - return await assetMetaForPath(root, destAbs) + const meta = await assetMetaForPath(root, destAbs) + if (willRename && meta.path !== source.rel) { + await updateAssetReferences(root, notesBefore, assetsBefore, source.rel, meta.path) + } + return meta +} + +/** + * Rewrite every reference to a renamed or moved asset across the vault (#785): the + * `![[embed]]` / `[[link]]` wikilinks and `![](href)` / `[](href)` markdown + * destinations that resolve to it. Only notes that can hold one are read: the + * ones flagged `hasAttachments` or carrying `assetEmbeds` (both cover embeds + * and file links), plus any whose plain wikilinks name a file that resolves to + * the asset. `notesBefore` / `assetsBefore` are the pre-rename snapshots, so + * resolution sees the asset under its old name. + */ +async function updateAssetReferences( + root: string, + notesBefore: NoteMeta[], + assetsBefore: AssetMeta[], + oldRel: string, + newRel: string +): Promise { + const candidates = notesBefore.filter( + (n) => + n.folder !== 'trash' && + (n.hasAttachments || + (n.assetEmbeds ?? []).length > 0 || + (n.wikilinks ?? []).some( + (t) => + localAssetTargetKind(t) !== null && + resolveAssetPathAmong(assetsBefore, n.path, t) === oldRel + )) + ) + for (const candidate of candidates) { + try { + const content = await readNote(root, candidate.path) + const { body, changed } = rewriteAssetReferences( + content.body, + assetsBefore, + candidate.path, + oldRel, + newRel + ) + if (changed > 0) await writeNote(root, candidate.path, body) + } catch (err) { + console.error('updateAssetReferences: failed for', candidate.path, err) + } + } } export async function moveAsset( @@ -3735,10 +3792,17 @@ export async function moveAsset( const destDir = cleanAssetTargetDir(root, targetDir) await fs.mkdir(destDir, { recursive: true }) if (path.resolve(destDir) === path.dirname(source.abs)) return await assetMetaForPath(root, source.abs) + // Snapshot before the move so references still resolve to the asset where + // it currently is; they are rewritten to the new location afterwards (#785). + const [assetsBefore, notesBefore] = await Promise.all([listAssets(root), listNotes(root)]) const finalName = await uniqueFilename(destDir, path.basename(source.abs)) const destAbs = path.join(destDir, finalName) if (destAbs !== source.abs) await fs.rename(source.abs, destAbs) - return await assetMetaForPath(root, destAbs) + const meta = await assetMetaForPath(root, destAbs) + if (meta.path !== source.rel) { + await updateAssetReferences(root, notesBefore, assetsBefore, source.rel, meta.path) + } + return meta } export async function duplicateAsset(root: string, rel: string): Promise { diff --git a/apps/server/internal/vault/asset_link_rename.go b/apps/server/internal/vault/asset_link_rename.go new file mode 100644 index 00000000..96cbc032 --- /dev/null +++ b/apps/server/internal/vault/asset_link_rename.go @@ -0,0 +1,401 @@ +package vault + +import ( + "net/url" + "regexp" + "sort" + "strings" +) + +// Rewriting references to an asset when the asset file is renamed or moved +// (#785). Port of packages/shared-domain/src/asset-link-rename.ts and of the +// resolver in asset-path-resolution.ts: a reference resolves relative to its +// note, then to the vault root, then by unique basename (the renderer's three +// readings); the ones that resolve to the asset are re-targeted in the +// author's own style (relative stays relative, rooted stays rooted, a bare +// name stays bare while unique) and keep everything else: `|alias` / `|300` +// hints, `#page=3` fragments, percent-encoding, angle brackets, link titles. + +var ( + assetWikilinkRe = regexp.MustCompile(`(!?)\[\[([^\]\n]+?)\]\]`) + // Matched from the `](` so the href of an image nested inside a link + // (`[![alt](a.png)](a.png)`) is found as readily as the outer link's own. + assetMdDestRe = regexp.MustCompile(`\]\(\s*(<[^>\n]*>|[^)\n]+?)((?:\s+(?:"[^"]*"|'[^']*'|\([^)]*\)))?)\s*\)`) +) + +func assetStripQueryAndHash(href string) string { + if i := strings.IndexByte(href, '#'); i >= 0 { + href = href[:i] + } + if i := strings.IndexByte(href, '?'); i >= 0 { + href = href[:i] + } + return href +} + +func assetDecodeHref(value string) string { + cleaned := assetStripQueryAndHash(value) + if decoded, err := url.PathUnescape(cleaned); err == nil { + return decoded + } + return cleaned +} + +func posixJoin(a, b string) string { + switch { + case a == "": + return b + case b == "": + return a + case strings.HasSuffix(a, "/"): + return a + b + } + return a + "/" + b +} + +func posixNormalize(input string) string { + out := []string{} + for _, part := range strings.Split(input, "/") { + switch part { + case "", ".": + continue + case "..": + if len(out) == 0 { + return ".." + } + out = out[:len(out)-1] + default: + out = append(out, part) + } + } + return strings.Join(out, "/") +} + +func lastPathSegment(p string) string { + parts := strings.Split(p, "/") + for i := len(parts) - 1; i >= 0; i-- { + if parts[i] != "" { + return parts[i] + } + } + return "" +} + +// Which of the three readings resolved a reference. Lets a rewrite keep the +// author's style: a note-relative href stays relative, a vault-root path stays +// rooted, a bare file name stays bare. +const ( + assetReadingNoteRelative = "note-relative" + assetReadingVaultRoot = "vault-root" + assetReadingBasename = "basename" +) + +type assetReferenceResolution struct { + path string + reading string + absolute bool // written with a leading `/` +} + +// resolveAssetPathAmong mirrors the renderer's resolveAssetPathAmong: the +// vault-relative path of the existing asset an href or wikilink target points +// at, or false when it points nowhere (or at more than one file by basename). +func resolveAssetPathAmong(assets []AssetMeta, notePath, href string) (string, bool) { + r, ok := resolveAssetReference(assets, notePath, href) + if !ok { + return "", false + } + return r.path, true +} + +// resolveAssetReference mirrors the renderer's resolveAssetReference: the +// resolved path plus the reading that found it. +func resolveAssetReference(assets []AssetMeta, notePath, href string) (assetReferenceResolution, bool) { + none := assetReferenceResolution{} + trimmed := strings.TrimSpace(href) + if trimmed == "" || strings.HasPrefix(trimmed, "#") || strings.HasPrefix(trimmed, "//") { + return none, false + } + if schemeRe.MatchString(trimmed) { + return none, false + } + noteDir := "" + if i := strings.LastIndexByte(notePath, '/'); i >= 0 { + noteDir = notePath[:i] + } + decoded := assetDecodeHref(trimmed) + isAbs := strings.HasPrefix(decoded, "/") + var target string + switch { + case isAbs: + target = strings.TrimLeft(decoded, "/") + case noteDir != "": + target = posixJoin(noteDir, decoded) + default: + target = decoded + } + target = posixNormalize(target) + if strings.HasPrefix(target, "../") || target == ".." { + return none, false + } + has := func(p string) bool { + for _, a := range assets { + if a.Path == p { + return true + } + } + return false + } + if has(target) { + reading := assetReadingNoteRelative + if isAbs || noteDir == "" { + reading = assetReadingVaultRoot + } + return assetReferenceResolution{path: target, reading: reading, absolute: isAbs}, true + } + if !isAbs && noteDir != "" { + rootTarget := posixNormalize(decoded) + if rootTarget != "" && rootTarget != target && !strings.HasPrefix(rootTarget, "../") && + rootTarget != ".." && has(rootTarget) { + return assetReferenceResolution{path: rootTarget, reading: assetReadingVaultRoot}, true + } + } + base := strings.ToLower(lastPathSegment(target)) + if base == "" { + return none, false + } + match, count := "", 0 + for _, a := range assets { + if strings.ToLower(lastPathSegment(a.Path)) == base { + match = a.Path + count++ + } + } + if count == 1 { + return assetReferenceResolution{path: match, reading: assetReadingBasename}, true + } + return none, false +} + +// assetRelativeTo is the POSIX path from directory fromDir ("" = vault root) to toPath. +func assetRelativeTo(fromDir, toPath string) string { + from := splitNonEmpty(fromDir) + to := splitNonEmpty(toPath) + shared := 0 + for shared < len(from) && shared < len(to) && from[shared] == to[shared] { + shared++ + } + parts := make([]string, 0, len(from)-shared+len(to)-shared) + for i := shared; i < len(from); i++ { + parts = append(parts, "..") + } + parts = append(parts, to[shared:]...) + return strings.Join(parts, "/") +} + +func splitNonEmpty(p string) []string { + out := []string{} + for _, part := range strings.Split(p, "/") { + if part != "" { + out = append(out, part) + } + } + return out +} + +// assetEncodeLike percent-encodes next per segment when the author wrote +// original encoded. +func assetEncodeLike(original, next string) string { + decoded, err := url.PathUnescape(original) + if err != nil || decoded == original { + return next + } + segments := strings.Split(next, "/") + for i, seg := range segments { + if seg != ".." && seg != "." { + segments[i] = url.PathEscape(seg) + } + } + return strings.Join(segments, "/") +} + +// assetRetarget rewrites one reference (a wikilink target or markdown href) +// that resolved via resolution so it points at newPath, in the author's style, +// keeping angle brackets, `#`/`?` suffix and percent-encoding. Wikilinks and +// hrefs differ in one place: a wikilink is written as a bare name or a +// vault-root path (Obsidian resolves them from the root), never with `..`, so +// a wikilink that happened to resolve next to its note stays bare while unique +// and otherwise gets the full path; a markdown href is a real relative path, +// so it follows the file with `..` where needed. +func assetRetarget(reference string, resolution assetReferenceResolution, noteDir, newPath string, bareStillUnique, wikilink bool) string { + angled := strings.HasPrefix(reference, "<") && strings.HasSuffix(reference, ">") + inner := reference + if angled { + inner = reference[1 : len(reference)-1] + } + suffix := "" + if i := strings.IndexAny(inner, "#?"); i >= 0 { + suffix = inner[i:] + inner = inner[:i] + } + bare := !strings.Contains(inner, "/") + var next string + switch { + case resolution.reading == assetReadingBasename || (wikilink && bare): + if bareStillUnique { + next = lastPathSegment(newPath) + } else { + next = newPath + } + case resolution.reading == assetReadingNoteRelative && !wikilink: + next = assetRelativeTo(noteDir, newPath) + default: + next = newPath + if resolution.absolute { + next = "/" + newPath + } + } + out := assetEncodeLike(inner, next) + suffix + if angled { + return "<" + out + ">" + } + return out +} + +// rewriteAssetReferencesInBody rewrites every reference in body that resolves +// to the asset at oldPath so it points at newPath (vault-relative; a rename +// changes the name, a move the directory). assets must be the pre-change +// listing so references resolve to the asset under the path they currently +// use; notePath is the note holding body, since a markdown href resolves +// relative to it. Code is skipped. +func rewriteAssetReferencesInBody(body string, assets []AssetMeta, notePath, oldPath, newPath string) (string, int) { + if newPath == "" || oldPath == newPath { + return body, 0 + } + if !strings.Contains(body, "[[") && !strings.Contains(body, "](") { + return body, 0 + } + noteDir := "" + if i := strings.LastIndexByte(notePath, '/'); i >= 0 { + noteDir = notePath[:i] + } + newBase := strings.ToLower(lastPathSegment(newPath)) + sameBase := 0 + for _, a := range assets { + p := a.Path + if p == oldPath { + p = newPath + } + if strings.ToLower(lastPathSegment(p)) == newBase { + sameBase++ + } + } + bareStillUnique := sameBase == 1 + resolveOld := func(reference string) (assetReferenceResolution, bool) { + t := strings.TrimSpace(reference) + if strings.HasPrefix(t, "<") && strings.HasSuffix(t, ">") { + t = t[1 : len(t)-1] + } + r, ok := resolveAssetReference(assets, notePath, t) + if !ok || r.path != oldPath { + return assetReferenceResolution{}, false + } + return r, true + } + type edit struct { + start, end int + text string + } + var edits []edit + mask := wikiCodeMask(body) + for _, m := range assetWikilinkRe.FindAllStringSubmatchIndex(body, -1) { + if mask[m[0]] { + continue + } + embed := body[m[2]:m[3]] + content := body[m[4]:m[5]] + target, rest := content, "" + if p := strings.IndexByte(content, '|'); p >= 0 { + target, rest = content[:p], content[p:] + } + r, ok := resolveOld(target) + if !ok { + continue + } + next := embed + "[[" + assetRetarget(target, r, noteDir, newPath, bareStillUnique, true) + rest + "]]" + if next == body[m[0]:m[1]] { + continue // a bare name that still resolves reads exactly as before + } + edits = append(edits, edit{m[0], m[1], next}) + } + for _, m := range assetMdDestRe.FindAllStringSubmatchIndex(body, -1) { + if mask[m[0]] { + continue + } + href := body[m[2]:m[3]] + title := "" + if m[4] >= 0 { + title = body[m[4]:m[5]] + } + r, ok := resolveOld(href) + if !ok { + continue + } + next := "](" + assetRetarget(href, r, noteDir, newPath, bareStillUnique, false) + title + ")" + if next == body[m[0]:m[1]] { + continue + } + edits = append(edits, edit{m[0], m[1], next}) + } + if len(edits) == 0 { + return body, 0 + } + sort.Slice(edits, func(i, j int) bool { return edits[i].start < edits[j].start }) + var sb strings.Builder + last, changed := 0, 0 + for _, e := range edits { + if e.start < last { + continue // overlapped an earlier edit; keep the first + } + sb.WriteString(body[last:e.start]) + sb.WriteString(e.text) + last = e.end + changed++ + } + sb.WriteString(body[last:]) + return sb.String(), changed +} + +// rewriteAssetReferences rewrites every note that referenced the renamed or moved asset. +// Only notes that can hold a reference are read: the ones flagged +// HasAttachments (embeds and file links), plus any whose plain wikilinks name +// a file that resolves to the asset. +func (v *Vault) rewriteAssetReferences(notesBefore []NoteMeta, assetsBefore []AssetMeta, oldRel, newRel string) { + for _, n := range notesBefore { + if n.Folder == FolderTrash { + continue + } + candidate := n.HasAttachments + if !candidate { + for _, t := range n.Wikilinks { + if localAssetTargetKind(t) == "" { + continue + } + if r, ok := resolveAssetPathAmong(assetsBefore, n.Path, t); ok && r == oldRel { + candidate = true + break + } + } + } + if !candidate { + continue + } + content, err := v.ReadNote(n.Path) + if err != nil { + continue + } + body, changed := rewriteAssetReferencesInBody(content.Body, assetsBefore, n.Path, oldRel, newRel) + if changed > 0 { + _, _ = v.WriteNote(n.Path, body) + } + } +} diff --git a/apps/server/internal/vault/asset_ops_test.go b/apps/server/internal/vault/asset_ops_test.go index e0d49eb9..acd14870 100644 --- a/apps/server/internal/vault/asset_ops_test.go +++ b/apps/server/internal/vault/asset_ops_test.go @@ -44,6 +44,206 @@ func TestRenameAssetInPlace(t *testing.T) { } } +func TestRewriteAssetReferencesOnRename(t *testing.T) { + assets := []AssetMeta{ + {Path: "assets/old.png"}, {Path: "assets/other.png"}, {Path: "assets/old name.png"}, + {Path: "assets/dup.png"}, {Path: "docs/dup.png"}, + } + note := "inbox/Daily/2026-09-15.md" + cases := []struct { + in, want string + changed int + }{ + {"Shot: ![[assets/old.png]]\n", "Shot: ![[assets/new.png]]\n", 1}, + {"![[assets/old.png|300]] [[assets/old.png|the shot]] [[/assets/old.png#top]]", + "![[assets/new.png|300]] [[assets/new.png|the shot]] [[/assets/new.png#top]]", 3}, + {"![alt](assets/old.png \"Title\")\n[open](../../assets/old.png)\n[p](/assets/old.png#page=2)", + "![alt](assets/new.png \"Title\")\n[open](../../assets/new.png)\n[p](/assets/new.png#page=2)", 3}, + {"[![shot](assets/old.png)](assets/old.png)", "[![shot](assets/new.png)](assets/new.png)", 2}, + {"![[old.png]] ![](old.png)", "![[new.png]] ![](new.png)", 2}, + {"![[dup.png]]", "![[dup.png]]", 0}, + {"`![[assets/old.png]]`\n```\n![[assets/old.png]]\n```\n~~~\n![](assets/old.png)\n~~~\n", + "`![[assets/old.png]]`\n```\n![[assets/old.png]]\n```\n~~~\n![](assets/old.png)\n~~~\n", 0}, + {"![[assets/other.png]] [[Old Note]] [web](https://x/assets/old.png) ![](//cdn/assets/old.png)", + "![[assets/other.png]] [[Old Note]] [web](https://x/assets/old.png) ![](//cdn/assets/old.png)", 0}, + } + for _, c := range cases { + got, n := rewriteAssetReferencesInBody(c.in, assets, note, "assets/old.png", "assets/new.png") + if got != c.want || n != c.changed { + t.Errorf("rewrite(%q) = %q (%d), want %q (%d)", c.in, got, n, c.want, c.changed) + } + } + got, n := rewriteAssetReferencesInBody("![](assets/old%20name.png) ![]() ![[assets/old name.png]]", + assets, note, "assets/old name.png", "assets/new name.png") + if want := "![](assets/new%20name.png) ![]() ![[assets/new name.png]]"; got != want || n != 3 { + t.Errorf("spaced rename = %q (%d), want %q (3)", got, n, want) + } + if got, n := rewriteAssetReferencesInBody("![[assets/old.png]]", assets, note, "assets/old.png", "assets/old.png"); got != "![[assets/old.png]]" || n != 0 { + t.Errorf("same-name rename changed the body: %q (%d)", got, n) + } +} + +func TestRewriteAssetReferencesOnMove(t *testing.T) { + assets := []AssetMeta{ + {Path: "assets/old.png"}, {Path: "assets/other.png"}, {Path: "assets/old name.png"}, + {Path: "assets/dup.png"}, {Path: "docs/dup.png"}, + } + note := "inbox/Daily/2026-09-15.md" + cases := []struct { + in, want, newPath string + changed int + }{ + // Vault-root wikilinks and hrefs re-root; a spelled-out leading slash stays. + {"![[assets/old.png]] [[assets/old.png|the shot]] [p](/assets/old.png#page=2)", + "![[media/shots/old.png]] [[media/shots/old.png|the shot]] [p](/media/shots/old.png#page=2)", "media/shots/old.png", 3}, + // Note-relative stays relative to the note. + {"[open](../../assets/old.png \"Title\")", "[open](../../media/shots/old.png \"Title\")", "media/shots/old.png", 1}, + {"![](../../assets/old.png)", "![](old.png)", "inbox/Daily/old.png", 1}, + // A bare name stays bare while it still names one asset. + {"![[old.png]] ![](old.png)", "![[old.png]] ![](old.png)", "media/shots/old.png", 0}, + // It spells out the path once the bare name would be ambiguous. + {"![[old.png]] ![[assets/old.png]]", "![[docs/dup.png]] ![[docs/dup.png]]", "docs/dup.png", 2}, + {"![[old.png]]", "![[assets/dup.png]]", "assets/dup.png", 1}, + } + for _, c := range cases { + got, n := rewriteAssetReferencesInBody(c.in, assets, note, "assets/old.png", c.newPath) + if got != c.want || n != c.changed { + t.Errorf("move rewrite(%q → %q) = %q (%d), want %q (%d)", c.in, c.newPath, got, n, c.want, c.changed) + } + } + // A note at the vault root writes the plain path either way. + if got, _ := rewriteAssetReferencesInBody("![](assets/old.png)", assets, "Root.md", "assets/old.png", "media/old.png"); got != "![](media/old.png)" { + t.Errorf("root-note move = %q", got) + } + // An asset that sat next to its note: hrefs go relative, wikilinks stay bare or go vault-root, never `..`. + local := []AssetMeta{{Path: "inbox/pic.png"}, {Path: "inbox/sub/chart.png"}, {Path: "assets/other.png"}} + if got, _ := rewriteAssetReferencesInBody("![[pic.png]] ![alt](pic.png) [[inbox/pic.png|the pic]] ![[assets/other.png]]", local, "inbox/Pics.md", "inbox/pic.png", "media/shots/pic.png"); got != "![[pic.png]] ![alt](../media/shots/pic.png) [[media/shots/pic.png|the pic]] ![[assets/other.png]]" { + t.Errorf("same-folder move = %q", got) + } + if got, _ := rewriteAssetReferencesInBody("![[sub/chart.png]] ![](sub/chart.png)", local, "inbox/Pics.md", "inbox/sub/chart.png", "media/chart.png"); got != "![[media/chart.png]] ![](../media/chart.png)" { + t.Errorf("relative-folder move = %q", got) + } + if got, _ := rewriteAssetReferencesInBody("![[pic.png]]", local, "inbox/Pics.md", "inbox/pic.png", "assets/other.png"); got != "![[assets/other.png]]" { + t.Errorf("bare-collision move = %q", got) + } + got, n := rewriteAssetReferencesInBody("![](assets/old%20name.png) ![]() `![[assets/old name.png]]`", + assets, note, "assets/old name.png", "media/new name.png") + if want := "![](media/new%20name.png) ![]() `![[assets/old name.png]]`"; got != want || n != 2 { + t.Errorf("encoded move = %q (%d), want %q (2)", got, n, want) + } +} + +// End-to-end: a real MoveAsset re-targets the notes that referenced the asset +// in the author's style and leaves the rest alone. (#785) +func TestMoveAssetRewritesReferences(t *testing.T) { + root := t.TempDir() + v, err := New(root, Options{}) + if err != nil { + t.Fatal(err) + } + writeAsset(t, root, "assets/shot.png", "PNG") + if _, err := v.WriteNote("inbox/Rooted.md", "![[assets/shot.png|300]]\n[page](/assets/shot.png#page=2)\n"); err != nil { + t.Fatal(err) + } + if _, err := v.WriteNote("inbox/Daily/2026-09-15.md", "![shot](../../assets/shot.png \"Shot\")\n"); err != nil { + t.Fatal(err) + } + if _, err := v.WriteNote("inbox/Bare.md", "See ![[shot.png]] and [the file](shot.png).\n"); err != nil { + t.Fatal(err) + } + bareBefore, err := os.Stat(filepath.Join(root, "inbox", "Bare.md")) + if err != nil { + t.Fatal(err) + } + + meta, err := v.MoveAsset("assets/shot.png", "media/screenshots") + if err != nil { + t.Fatal(err) + } + if meta.Path != "media/screenshots/shot.png" { + t.Fatalf("moved path = %q, want media/screenshots/shot.png", meta.Path) + } + rooted, err := v.ReadNote("inbox/Rooted.md") + if err != nil { + t.Fatal(err) + } + if want := "![[media/screenshots/shot.png|300]]\n[page](/media/screenshots/shot.png#page=2)\n"; rooted.Body != want { + t.Fatalf("Rooted after move =\n%q\nwant\n%q", rooted.Body, want) + } + daily, err := v.ReadNote("inbox/Daily/2026-09-15.md") + if err != nil { + t.Fatal(err) + } + if want := "![shot](../../media/screenshots/shot.png \"Shot\")\n"; daily.Body != want { + t.Fatalf("Daily after move = %q, want %q", daily.Body, want) + } + bareAfter, err := os.Stat(filepath.Join(root, "inbox", "Bare.md")) + if err != nil { + t.Fatal(err) + } + if !bareAfter.ModTime().Equal(bareBefore.ModTime()) { + t.Errorf("bare-name note was rewritten though its links still resolve") + } +} + +// End-to-end: a real RenameAsset rewrites the notes that referenced the asset +// and leaves the rest alone. (#785) +func TestRenameAssetRewritesReferences(t *testing.T) { + root := t.TempDir() + v, err := New(root, Options{}) + if err != nil { + t.Fatal(err) + } + writeAsset(t, root, "assets/shot.png", "PNG") + writeAsset(t, root, "assets/other.png", "PNG") + body := "![[assets/shot.png]] ![[assets/shot.png|300]] ![alt](assets/shot.png \"Shot\") [[assets/shot.png|open]] ![[shot.png]]\n\n`![[assets/shot.png]]` ![[assets/other.png]]\n" + if _, err := v.WriteNote("inbox/Embeds.md", body); err != nil { + t.Fatal(err) + } + // A plain file link is neither an embed nor a note wikilink: HasAttachments carries it. + if _, err := v.WriteNote("inbox/Bare.md", "See [the file](shot.png).\n"); err != nil { + t.Fatal(err) + } + if _, err := v.WriteNote("inbox/Plain.md", "No pictures, just [[Embeds]].\n"); err != nil { + t.Fatal(err) + } + plainBefore, err := os.Stat(filepath.Join(root, "inbox", "Plain.md")) + if err != nil { + t.Fatal(err) + } + + meta, err := v.RenameAsset("assets/shot.png", "screenshot.png") + if err != nil { + t.Fatal(err) + } + if meta.Path != "assets/screenshot.png" { + t.Fatalf("renamed path = %q, want assets/screenshot.png", meta.Path) + } + + got, err := v.ReadNote("inbox/Embeds.md") + if err != nil { + t.Fatal(err) + } + want := "![[assets/screenshot.png]] ![[assets/screenshot.png|300]] ![alt](assets/screenshot.png \"Shot\") [[assets/screenshot.png|open]] ![[screenshot.png]]\n\n`![[assets/shot.png]]` ![[assets/other.png]]\n" + if got.Body != want { + t.Fatalf("Embeds after rename =\n%q\nwant\n%q", got.Body, want) + } + bare, err := v.ReadNote("inbox/Bare.md") + if err != nil { + t.Fatal(err) + } + if bare.Body != "See [the file](screenshot.png).\n" { + t.Fatalf("Bare after rename = %q", bare.Body) + } + plainAfter, err := os.Stat(filepath.Join(root, "inbox", "Plain.md")) + if err != nil { + t.Fatal(err) + } + if !plainAfter.ModTime().Equal(plainBefore.ModTime()) { + t.Errorf("a note without references was rewritten") + } +} + func TestRenameAssetRejectsCollision(t *testing.T) { root := t.TempDir() v, err := New(root, Options{}) diff --git a/apps/server/internal/vault/vault.go b/apps/server/internal/vault/vault.go index 35c98fa0..a52bfcff 100644 --- a/apps/server/internal/vault/vault.go +++ b/apps/server/internal/vault/vault.go @@ -2668,17 +2668,41 @@ func (v *Vault) AssetAbsPath(rel string) (string, error) { // RenameAsset renames an asset file in place (same directory), mirroring the // desktop renameAsset. It refuses internal files and markdown notes, and -// handles a case-only rename on case-insensitive filesystems. (#379) +// handles a case-only rename on case-insensitive filesystems. (#379) Every +// note that referenced the asset is then rewritten to its new name, the way +// RenameNote handles inbound wikilinks. (#785) func (v *Vault) RenameAsset(rel, nextName string) (AssetMeta, error) { + // Snapshot before the move (both listings take their own read locks) so + // references still resolve to the asset under its current name; they are + // rewritten after the rename's write lock has been released. + assetsBefore, _ := v.ListAssets() + notesBefore, _ := v.ListNotes() + meta, oldRel, err := v.renameAssetFile(rel, nextName) + if err != nil { + return AssetMeta{}, err + } + if meta.Path != oldRel { + v.rewriteAssetReferences(notesBefore, assetsBefore, oldRel, meta.Path) + } + return meta, nil +} + +// renameAssetFile is the locked file move behind RenameAsset. It returns the +// new meta and the asset's vault-relative path before the move. +func (v *Vault) renameAssetFile(rel, nextName string) (AssetMeta, string, error) { v.mu.Lock() defer v.mu.Unlock() srcAbs, err := v.assertAssetFile(rel) if err != nil { - return AssetMeta{}, err + return AssetMeta{}, "", err + } + before, err := v.assetMetaForAbs(srcAbs) + if err != nil { + return AssetMeta{}, "", err } cleanName, err := cleanAssetFilename(nextName) if err != nil { - return AssetMeta{}, err + return AssetMeta{}, "", err } destAbs := filepath.Join(filepath.Dir(srcAbs), cleanName) if destAbs != srcAbs { @@ -2688,55 +2712,77 @@ func (v *Vault) RenameAsset(rel, nextName string) (AssetMeta, error) { // filesystem), routing through a temp name; otherwise it collides. srcInfo, srcErr := os.Stat(srcAbs) if srcErr != nil { - return AssetMeta{}, srcErr + return AssetMeta{}, "", srcErr } if !os.SameFile(dstInfo, srcInfo) { - return AssetMeta{}, fmt.Errorf("an asset named %q already exists in this folder", cleanName) + return AssetMeta{}, "", fmt.Errorf("an asset named %q already exists in this folder", cleanName) } tmp := srcAbs + ".zenrename.tmp" if err := os.Rename(srcAbs, tmp); err != nil { - return AssetMeta{}, err + return AssetMeta{}, "", err } if err := os.Rename(tmp, destAbs); err != nil { - return AssetMeta{}, err + return AssetMeta{}, "", err } } else if !errors.Is(statErr, os.ErrNotExist) { - return AssetMeta{}, statErr + return AssetMeta{}, "", statErr } else if err := os.Rename(srcAbs, destAbs); err != nil { - return AssetMeta{}, err + return AssetMeta{}, "", err } } - return v.assetMetaForAbs(destAbs) + meta, err := v.assetMetaForAbs(destAbs) + return meta, before.Path, err } // MoveAsset moves an asset file into targetDir (vault-relative; empty means the // unified assets/ folder), mirroring the desktop moveAsset. The filename is made -// unique in the destination. (#379) +// unique in the destination. (#379) Every note that referenced the asset is +// then re-targeted to its new location, like RenameAsset. (#785) func (v *Vault) MoveAsset(rel, targetDir string) (AssetMeta, error) { + assetsBefore, _ := v.ListAssets() + notesBefore, _ := v.ListNotes() + meta, oldRel, err := v.moveAssetFile(rel, targetDir) + if err != nil { + return AssetMeta{}, err + } + if meta.Path != oldRel { + v.rewriteAssetReferences(notesBefore, assetsBefore, oldRel, meta.Path) + } + return meta, nil +} + +// moveAssetFile is the locked file move behind MoveAsset. It returns the new +// meta and the asset's vault-relative path before the move. +func (v *Vault) moveAssetFile(rel, targetDir string) (AssetMeta, string, error) { v.mu.Lock() defer v.mu.Unlock() srcAbs, err := v.assertAssetFile(rel) if err != nil { - return AssetMeta{}, err + return AssetMeta{}, "", err + } + before, err := v.assetMetaForAbs(srcAbs) + if err != nil { + return AssetMeta{}, "", err } destDir, err := v.cleanAssetTargetDir(targetDir) if err != nil { - return AssetMeta{}, err + return AssetMeta{}, "", err } if err := os.MkdirAll(destDir, v.dirMode); err != nil { - return AssetMeta{}, err + return AssetMeta{}, "", err } if filepath.Clean(destDir) == filepath.Clean(filepath.Dir(srcAbs)) { - return v.assetMetaForAbs(srcAbs) + return before, before.Path, nil } name := filepath.Base(srcAbs) ext := filepath.Ext(name) stem := strings.TrimSuffix(name, ext) destAbs := uniquePath(destDir, stem, ext) if err := os.Rename(srcAbs, destAbs); err != nil { - return AssetMeta{}, err + return AssetMeta{}, "", err } - return v.assetMetaForAbs(destAbs) + meta, err := v.assetMetaForAbs(destAbs) + return meta, before.Path, err } // assertAssetFile validates rel points at an existing, editable asset file and diff --git a/packages/app-core/src/components/AssetsView.tsx b/packages/app-core/src/components/AssetsView.tsx index 55e92d8b..406e0020 100644 --- a/packages/app-core/src/components/AssetsView.tsx +++ b/packages/app-core/src/components/AssetsView.tsx @@ -114,6 +114,7 @@ export function AssetsView(): JSX.Element { const openNoteInTab = useStore((s) => s.openNoteInTab) const deleteAsset = useStore((s) => s.deleteAsset) const refreshAssets = useStore((s) => s.refreshAssets) + const renameAssetFile = useStore((s) => s.renameAsset) const notes = useStore((s) => s.notes) const vaultRoot = useStore((s) => s.vault?.root ?? null) const [filter, setFilter] = useState('') @@ -180,8 +181,7 @@ export function AssetsView(): JSX.Element { const clean = next.trim() if (!clean || `${clean}${ext}` === asset.name) return try { - await window.zen.renameAsset(asset.path, `${clean}${ext}`) - await refreshAssets() + await renameAssetFile(asset.path, `${clean}${ext}`) } catch (err) { window.alert(err instanceof Error ? err.message : String(err)) } diff --git a/packages/app-core/src/components/NoteList.tsx b/packages/app-core/src/components/NoteList.tsx index 33d61253..98937074 100644 --- a/packages/app-core/src/components/NoteList.tsx +++ b/packages/app-core/src/components/NoteList.tsx @@ -82,6 +82,8 @@ export function NoteList(): JSX.Element { const toggleNoteList = useStore((s) => s.toggleNoteList) const refreshNotes = useStore((s) => s.refreshNotes) const refreshAssets = useStore((s) => s.refreshAssets) + const renameAsset = useStore((s) => s.renameAsset) + const moveAsset = useStore((s) => s.moveAsset) const deleteAssetAction = useStore((s) => s.deleteAsset) const noteListWidth = useStore((s) => s.noteListWidth) const setNoteListWidth = useStore((s) => s.setNoteListWidth) @@ -350,8 +352,7 @@ export function NoteList(): JSX.Element { } }) if (!next || next === asset.name) return - await window.zen.renameAsset(asset.path, next) - await refreshAssets() + await renameAsset(asset.path, next) } }) items.push({ @@ -374,8 +375,7 @@ export function NoteList(): JSX.Element { } }) if (target === null || target === currentDir) return - await window.zen.moveAsset(asset.path, target) - await refreshAssets() + await moveAsset(asset.path, target) } }) items.push({ diff --git a/packages/app-core/src/components/Preview.tsx b/packages/app-core/src/components/Preview.tsx index a027c1a3..0eed070e 100644 --- a/packages/app-core/src/components/Preview.tsx +++ b/packages/app-core/src/components/Preview.tsx @@ -202,6 +202,8 @@ export const Preview = memo(function Preview({ const assetFiles = useStore((s) => s.assetFiles); const customCodeLanguagesRevision = useStore((s) => s.customCodeLanguagesRevision); const refreshAssets = useStore((s) => s.refreshAssets); + const renameAsset = useStore((s) => s.renameAsset); + const moveAsset = useStore((s) => s.moveAsset); const deleteAssetAction = useStore((s) => s.deleteAsset); const diagramTheme = useDiagramTheme(); const selectNote = useStore((s) => s.selectNote); @@ -980,8 +982,7 @@ export const Preview = memo(function Preview({ }, }); if (!next || next === asset.name) return; - await window.zen.renameAsset(vaultRel, next); - await refreshAssets(); + await renameAsset(vaultRel, next); }, }); items.push({ @@ -1004,8 +1005,7 @@ export const Preview = memo(function Preview({ }, }); if (target === null || target === currentDir) return; - await window.zen.moveAsset(vaultRel, target); - await refreshAssets(); + await moveAsset(vaultRel, target); }, }); items.push({ diff --git a/packages/app-core/src/components/Sidebar.tsx b/packages/app-core/src/components/Sidebar.tsx index c409d81d..7128af26 100644 --- a/packages/app-core/src/components/Sidebar.tsx +++ b/packages/app-core/src/components/Sidebar.tsx @@ -518,6 +518,8 @@ export function Sidebar(): JSX.Element { const revealFolderAction = useStore((s) => s.revealFolder); const revealAssetsDir = useStore((s) => s.revealAssetsDir); const refreshAssets = useStore((s) => s.refreshAssets); + const renameAsset = useStore((s) => s.renameAsset); + const moveAsset = useStore((s) => s.moveAsset); const deleteAssetAction = useStore((s) => s.deleteAsset); const sidebarWidth = useStore((s) => s.sidebarWidth); const showWindowTitleBar = useStore((s) => s.showWindowTitleBar); @@ -957,8 +959,7 @@ export function Sidebar(): JSX.Element { const curDir = slash === -1 ? "" : payload.path.slice(0, slash); if (curDir === targetDir) return; // already in this folder try { - await window.zen.moveAsset(payload.path, targetDir); - await refreshAssets(); + await moveAsset(payload.path, targetDir); } catch (err) { window.alert((err as Error).message); } @@ -2572,8 +2573,7 @@ export function Sidebar(): JSX.Element { }, }); if (!next || next === asset.name) return; - await window.zen.renameAsset(asset.path, next); - await refreshAssets(); + await renameAsset(asset.path, next); }, }); items.push({ @@ -2596,8 +2596,7 @@ export function Sidebar(): JSX.Element { }, }); if (target === null || target === currentDir) return; - await window.zen.moveAsset(asset.path, target); - await refreshAssets(); + await moveAsset(asset.path, target); }, }); items.push({ diff --git a/packages/app-core/src/lib/asset-path-resolution.ts b/packages/app-core/src/lib/asset-path-resolution.ts new file mode 100644 index 00000000..4c1f1ebd --- /dev/null +++ b/packages/app-core/src/lib/asset-path-resolution.ts @@ -0,0 +1,4 @@ +// The resolver moved to shared-domain so the desktop main process can rewrite +// asset references with the exact rules the renderer resolves them by (#785). +// Re-exported here so the renderer's import sites stay put. +export * from '@shared/asset-path-resolution' diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index b5c54376..b989396f 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -417,7 +417,7 @@ export const HELP_CORE_CONCEPTS: HelpCard[] = [ { title: 'Files stay local', body: - 'Drop files into a note to insert local files. ZenNotes copies them into the vault’s `assets/` folder — the same place pasted images land — so they stay together instead of cluttering your notes area, whether you keep notes in `inbox/` or at the vault root. It can reveal them from the app, and opens images, SVGs, PDFs, audio, video, and generic files inside ZenNotes tabs or reference panes where possible. In the sidebar you can drag an image, PDF, or any attachment onto a folder to move it, just like a note, or use its Move… context-menu entry.' + 'Drop files into a note to insert local files. ZenNotes copies them into the vault’s `assets/` folder — the same place pasted images land — so they stay together instead of cluttering your notes area, whether you keep notes in `inbox/` or at the vault root. It can reveal them from the app, and opens images, SVGs, PDFs, audio, video, and generic files inside ZenNotes tabs or reference panes where possible. In the sidebar you can drag an image, PDF, or any attachment onto a folder to move it, just like a note, or use its Move… context-menu entry. Renaming or moving an asset (from the sidebar, the Assets view, a rendered image’s menu, or by dragging it onto a folder) rewrites every note that referenced it, `![[assets/old.png]]` embeds and `![](old.png)` links alike, in the style each link was written, the same way renaming a note updates its wikilinks.' }, { title: 'Any CSV is a database', diff --git a/packages/app-core/src/store.ts b/packages/app-core/src/store.ts index 99047ab2..a1df05d7 100644 --- a/packages/app-core/src/store.ts +++ b/packages/app-core/src/store.ts @@ -3319,6 +3319,13 @@ interface Store { /** Dismiss the vault-root notice for the current vault, persisted (#216). */ dismissRootContentBanner: () => void refreshAssets: () => Promise + /** Rename an asset file in place. Every note referencing it is rewritten on + * disk (#785), so open buffers are flushed first and notes re-listed after. */ + renameAsset: (relPath: string, nextName: string) => Promise + /** Move an asset into another vault folder. Like `renameAsset`, every note + * referencing it is re-targeted on disk (#785), so buffers flush first and + * notes re-list after. */ + moveAsset: (relPath: string, targetDir: string) => Promise deleteAsset: (relPath: string) => Promise undoLastAssetAction: () => Promise updateActiveBody: (body: string) => void @@ -6291,6 +6298,32 @@ export const useStore = create((set, get) => { } }, + renameAsset: async (relPath, nextName) => { + // Renaming rewrites every note that references the asset on disk. Flush + // open buffers first so that rewrite cannot race a pending save and get + // overwritten by stale editor contents immediately afterwards (#785), the + // same guard `renameNote` uses for inbound wikilinks. + await get().flushDirtyNotes() + if (Object.values(get().noteDirty).some(Boolean)) { + throw new Error('Could not rename while notes still have unsaved changes') + } + const meta = await window.zen.renameAsset(relPath, nextName) + // Assets for the list; notes so `assetEmbeds` (usage) and excerpts follow + // the rewritten bodies. + await Promise.all([get().refreshAssets(), get().refreshNotes()]) + return meta + }, + moveAsset: async (relPath, targetDir) => { + // Same guard as renameAsset: the move rewrites referencing notes on disk, + // which must not race a pending save (#785). + await get().flushDirtyNotes() + if (Object.values(get().noteDirty).some(Boolean)) { + throw new Error('Could not move while notes still have unsaved changes') + } + const meta = await window.zen.moveAsset(relPath, targetDir) + await Promise.all([get().refreshAssets(), get().refreshNotes()]) + return meta + }, refreshAssets: async () => { try { const startedAt = performance.now() diff --git a/packages/shared-domain/src/asset-link-rename.test.ts b/packages/shared-domain/src/asset-link-rename.test.ts new file mode 100644 index 00000000..ae2585d0 --- /dev/null +++ b/packages/shared-domain/src/asset-link-rename.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from 'vitest' +import { rewriteAssetReferences } from './asset-link-rename' + +const assets = [ + { path: 'assets/old.png' }, + { path: 'assets/other.png' }, + { path: 'assets/old name.png' }, + { path: 'docs/manual.pdf' }, + { path: 'assets/dup.png' }, + { path: 'docs/dup.png' } +] + +const note = 'inbox/Daily/2026-09-15.md' + +function rewrite(body: string, oldPath = 'assets/old.png', newPath = 'assets/new.png') { + return rewriteAssetReferences(body, assets, note, oldPath, newPath) +} + +describe('rewriteAssetReferences on rename (#785)', () => { + it('rewrites the vault-relative embed the paste flow writes', () => { + expect(rewrite('Shot: ![[assets/old.png]]\n')).toEqual({ + body: 'Shot: ![[assets/new.png]]\n', + changed: 1 + }) + }) + + it('keeps size hints, aliases, fragments and the plain (non-embed) wikilink form', () => { + const body = '![[assets/old.png|300]] [[assets/old.png|the shot]] [[/assets/old.png#top]]' + expect(rewrite(body).body).toBe( + '![[assets/new.png|300]] [[assets/new.png|the shot]] [[/assets/new.png#top]]' + ) + }) + + it('rewrites markdown images and links, keeping titles and note-relative paths', () => { + const body = [ + '![alt](assets/old.png "Title")', + '[open](../../assets/old.png)', + '[page two](/assets/old.png#page=2)' + ].join('\n') + expect(rewrite(body).body).toBe( + [ + '![alt](assets/new.png "Title")', + '[open](../../assets/new.png)', + '[page two](/assets/new.png#page=2)' + ].join('\n') + ) + }) + + it('keeps the author\'s percent-encoding and angle brackets around spaced names', () => { + const body = '![](assets/old%20name.png) ![]() ![[assets/old name.png]]' + const out = rewrite(body, 'assets/old name.png', 'assets/new name.png') + expect(out.body).toBe( + '![](assets/new%20name.png) ![]() ![[assets/new name.png]]' + ) + expect(out.changed).toBe(3) + }) + + it('rewrites both hrefs of an image nested inside a link to itself', () => { + expect(rewrite('[![shot](assets/old.png)](assets/old.png)').body).toBe( + '[![shot](assets/new.png)](assets/new.png)' + ) + }) + + it('follows a bare basename only while it is unique in the vault', () => { + expect(rewrite('![[old.png]] ![](old.png)').body).toBe('![[new.png]] ![](new.png)') + // Two `dup.png` files: the link was already ambiguous, so it is left alone. + expect(rewrite('![[dup.png]]', 'assets/dup.png', 'assets/renamed.png')).toEqual({ + body: '![[dup.png]]', + changed: 0 + }) + }) + + it('leaves code, other assets, notes, and URLs untouched', () => { + const body = [ + '`![[assets/old.png]]` and `[x](assets/old.png)`', + '```', + '![[assets/old.png]]', + '```', + '~~~md', + '![](assets/old.png)', + '~~~', + '![[assets/other.png]] [[Old Note]] [[docs/manual.pdf]]', + '[web](https://example.com/assets/old.png) ![](//cdn/assets/old.png)' + ].join('\n') + expect(rewrite(body)).toEqual({ body, changed: 0 }) + }) + + it('does nothing when the name did not change', () => { + const body = '![[assets/old.png]]' + expect(rewrite(body, 'assets/old.png', 'assets/old.png')).toEqual({ body, changed: 0 }) + }) + + it('handles a case-only rename', () => { + expect(rewrite('![[assets/old.png]]', 'assets/old.png', 'assets/OLD.png').body).toBe( + '![[assets/OLD.png]]' + ) + }) +}) + +describe('rewriteAssetReferences on move (#785 follow-up)', () => { + const move = (body: string, oldPath = 'assets/old.png', newPath = 'media/shots/old.png') => + rewriteAssetReferences(body, assets, note, oldPath, newPath) + + it('re-roots vault-root wikilinks and hrefs, keeping a spelled-out leading slash', () => { + expect(move('![[assets/old.png]] [[assets/old.png|the shot]] [p](/assets/old.png#page=2)').body).toBe( + '![[media/shots/old.png]] [[media/shots/old.png|the shot]] [p](/media/shots/old.png#page=2)' + ) + }) + + it('keeps a note-relative href relative to the note', () => { + // The note sits in inbox/Daily/, so the new location is reached the same way. + expect(move('[open](../../assets/old.png "Title")').body).toBe('[open](../../media/shots/old.png "Title")') + // A note at the vault root writes the plain path either way. + expect(rewriteAssetReferences('![](assets/old.png)', assets, 'Root.md', 'assets/old.png', 'media/old.png').body).toBe( + '![](media/old.png)' + ) + // Moving into the note\'s own folder yields a bare relative name. + expect(rewriteAssetReferences('![](../../assets/old.png)', assets, note, 'assets/old.png', 'inbox/Daily/old.png').body).toBe( + '![](old.png)' + ) + }) + + it('leaves a bare file name bare while it still names one asset', () => { + expect(move('![[old.png]] ![](old.png)')).toEqual({ body: '![[old.png]] ![](old.png)', changed: 0 }) + }) + + it('spells out the path when the bare name would become ambiguous', () => { + // Moving old.png into docs/ as dup.png collides with docs/dup.png\'s twin assets/dup.png. + expect(move('![[old.png]] ![[assets/old.png]]', 'assets/old.png', 'docs/dup.png').body).toBe( + '![[docs/dup.png]] ![[docs/dup.png]]' + ) + // Same for a rename that lands on a name another folder already uses. + expect(move('![[old.png]]', 'assets/old.png', 'assets/dup.png').body).toBe('![[assets/dup.png]]') + }) + + it('moves an asset that sat next to its note: hrefs go relative, wikilinks stay bare or go vault-root', () => { + const local = [{ path: 'inbox/pic.png' }, { path: 'inbox/sub/chart.png' }, { path: 'assets/other.png' }] + const body = '![[pic.png]] ![alt](pic.png) [[inbox/pic.png|the pic]] ![[assets/other.png]]' + expect(rewriteAssetReferences(body, local, 'inbox/Pics.md', 'inbox/pic.png', 'media/shots/pic.png').body).toBe( + '![[pic.png]] ![alt](../media/shots/pic.png) [[media/shots/pic.png|the pic]] ![[assets/other.png]]' + ) + // A wikilink with a note-relative folder in it becomes a vault-root path, never `../`. + expect(rewriteAssetReferences('![[sub/chart.png]] ![](sub/chart.png)', local, 'inbox/Pics.md', 'inbox/sub/chart.png', 'media/chart.png').body).toBe( + '![[media/chart.png]] ![](../media/chart.png)' + ) + // A bare wikilink whose name stops being unique spells out the path instead. + expect(rewriteAssetReferences('![[pic.png]]', local, 'inbox/Pics.md', 'inbox/pic.png', 'assets/other.png').body).toBe( + '![[assets/other.png]]' + ) + }) + + it('keeps encoding, angle brackets and code untouched across a move', () => { + const out = rewriteAssetReferences( + '![](assets/old%20name.png) ![]() `![[assets/old name.png]]`', + assets, + note, + 'assets/old name.png', + 'media/new name.png' + ) + expect(out).toEqual({ + body: '![](media/new%20name.png) ![]() `![[assets/old name.png]]`', + changed: 2 + }) + }) +}) diff --git a/packages/shared-domain/src/asset-link-rename.ts b/packages/shared-domain/src/asset-link-rename.ts new file mode 100644 index 00000000..8bef8d01 --- /dev/null +++ b/packages/shared-domain/src/asset-link-rename.ts @@ -0,0 +1,175 @@ +/** + * Rewriting references to an asset when the asset file is renamed or moved + * (#785). + * + * Renaming a note already rewrites its inbound `[[wikilinks]]`; renaming or + * moving an asset used to leave every `![[assets/old.png]]` pointing at a file + * that no longer exists. This walks a note body for the ways an asset gets + * referenced, `![[embed]]` / `[[link]]` wikilinks and `![](href)` / `[](href)` + * markdown destinations, resolves each exactly the way the renderer does + * (`resolveAssetReference`: relative to the note, then to the vault root, then + * a unique basename) and re-targets the ones that pointed at the asset. The + * new reference is written in the author's own style: a note-relative href + * stays relative, a vault-root path stays rooted (with its leading `/` if it + * had one), a bare file name stays bare while it is still unique in the vault + * and otherwise becomes the full path; a wikilink never gains `..` segments, + * it is bare or vault-root like the ones people type. Everything else about + * the reference + * survives: `|alias` and `|300` size hints, `#page=3` fragments, `?` queries, + * percent-encoding, `` around a spaced path, and link titles. + * Code spans and fenced blocks are skipped. + * + * Pure and store-free so the desktop main process (which cannot import the + * renderer bundle) and app-core share one implementation; the Go server + * carries a port in `internal/vault/asset_link_rename.go`. + */ +import { + resolveAssetReference, + type AssetPathRef, + type AssetReferenceResolution +} from './asset-path-resolution' + +// Code first so references inside spans and fences are left untouched, then a +// wikilink, then a markdown destination. The destination is matched from its +// `](` rather than from the link's opening bracket so the href of an image +// nested inside a link (`[![alt](a.png)](a.png)`) is found as readily as the +// outer link's own. +const TOKEN_RE = + /(```[\s\S]*?```|~~~[\s\S]*?~~~|`[^`\n]*`)|(!?)\[\[([^\]\n]+?)\]\]|\]\(\s*(<[^>\n]*>|[^)\n]+?)((?:\s+(?:"[^"]*"|'[^']*'|\([^)]*\)))?)\s*\)/g + +export interface AssetLinkRewrite { + body: string + /** Number of references rewritten. */ + changed: number +} + +function basenameOf(path: string): string { + return path.slice(path.lastIndexOf('/') + 1) +} + +/** A `#fragment` or `?query` belongs to the reference, not to the file name. */ +function splitSuffix(reference: string): { path: string; suffix: string } { + const cut = reference.search(/[#?]/) + if (cut < 0) return { path: reference, suffix: '' } + return { path: reference.slice(0, cut), suffix: reference.slice(cut) } +} + +/** POSIX path from directory `fromDir` ('' = vault root) to `toPath`. */ +function relativeTo(fromDir: string, toPath: string): string { + const from = fromDir.split('/').filter(Boolean) + const to = toPath.split('/').filter(Boolean) + let shared = 0 + while (shared < from.length && shared < to.length && from[shared] === to[shared]) shared++ + return [...from.slice(shared).map(() => '..'), ...to.slice(shared)].join('/') +} + +/** Percent-encode `next` per segment when the author wrote `original` encoded. */ +function encodeLike(original: string, next: string): string { + let decoded = original + try { + decoded = decodeURIComponent(original) + } catch { + /* not percent-encoded; keep the raw name */ + } + if (decoded === original) return next + return next + .split('/') + .map((segment) => (segment === '..' || segment === '.' ? segment : encodeURIComponent(segment))) + .join('/') +} + +/** + * Rewrite every reference in `body` that resolves to the asset at `oldPath` + * so it points at `newPath` (both vault-relative POSIX paths; a rename changes + * the name, a move the directory, and a move into a folder that already holds + * the name changes both). `assets` must be the pre-change listing, so + * references resolve to the asset under the path they currently use. + * `notePath` is the note holding `body`, since a markdown href resolves + * relative to it. + */ +export function rewriteAssetReferences( + body: string, + assets: ReadonlyArray, + notePath: string, + oldPath: string, + newPath: string +): AssetLinkRewrite { + if (!newPath || oldPath === newPath) return { body, changed: 0 } + if (!body.includes('[[') && !body.includes('](')) return { body, changed: 0 } + + const noteDir = notePath.includes('/') ? notePath.slice(0, notePath.lastIndexOf('/')) : '' + // The listing as it reads after the change decides whether a bare file name + // still names exactly one asset. + const assetsAfter = assets.map((asset) => (asset.path === oldPath ? { path: newPath } : asset)) + const newBase = basenameOf(newPath) + const newBaseLower = newBase.toLowerCase() + const bareStillUnique = + assetsAfter.filter((asset) => basenameOf(asset.path).toLowerCase() === newBaseLower).length === 1 + + const resolveOld = (reference: string): AssetReferenceResolution | null => { + const trimmed = reference.trim() + const inner = + trimmed.startsWith('<') && trimmed.endsWith('>') ? trimmed.slice(1, -1) : trimmed + const resolution = resolveAssetReference(assets, notePath, inner) + return resolution && resolution.path === oldPath ? resolution : null + } + + // Wikilinks and hrefs differ in one place: a wikilink is written as a bare + // name or a vault-root path (Obsidian resolves them from the root), never + // with `..`, so a wikilink that happened to resolve next to its note stays + // bare while unique and otherwise gets the full path. A markdown href is a + // real relative path, so it follows the file with `..` where needed. + const retarget = ( + reference: string, + resolution: AssetReferenceResolution, + kind: 'wikilink' | 'href' + ): string => { + const angled = reference.startsWith('<') && reference.endsWith('>') + const inner = angled ? reference.slice(1, -1) : reference + const { path, suffix } = splitSuffix(inner) + const bare = !path.includes('/') + let next: string + if (resolution.reading === 'basename' || (kind === 'wikilink' && bare)) { + next = bareStillUnique ? newBase : newPath + } else if (resolution.reading === 'note-relative' && kind === 'href') { + next = relativeTo(noteDir, newPath) + } else { + next = resolution.absolute ? `/${newPath}` : newPath + } + const out = `${encodeLike(path, next)}${suffix}` + return angled ? `<${out}>` : out + } + + let changed = 0 + const next = body.replace( + TOKEN_RE, + ( + full: string, + code: string | undefined, + embed: string | undefined, + wikiContent: string | undefined, + href: string | undefined, + title: string | undefined + ) => { + if (code !== undefined) return full + if (wikiContent !== undefined) { + const pipe = wikiContent.indexOf('|') + const target = pipe >= 0 ? wikiContent.slice(0, pipe) : wikiContent + const rest = pipe >= 0 ? wikiContent.slice(pipe) : '' + const resolution = resolveOld(target) + if (!resolution) return full + const next = `${embed ?? ''}[[${retarget(target, resolution, 'wikilink')}${rest}]]` + // A bare name that still resolves after a move reads exactly as before. + if (next !== full) changed++ + return next + } + if (href === undefined) return full + const resolution = resolveOld(href) + if (!resolution) return full + const next = `](${retarget(href, resolution, 'href')}${title ?? ''})` + if (next !== full) changed++ + return next + } + ) + return { body: next, changed } +} diff --git a/packages/shared-domain/src/asset-path-resolution.ts b/packages/shared-domain/src/asset-path-resolution.ts index be151f9e..53f6adf4 100644 --- a/packages/shared-domain/src/asset-path-resolution.ts +++ b/packages/shared-domain/src/asset-path-resolution.ts @@ -4,7 +4,9 @@ * hold the list (the Connections panel's outgoing links, the follow-link path) * resolve without a store round trip, and the rules are unit-tested without * booting the store. `resolveAssetVaultRelativePath` in local-assets.ts wraps - * this with the live `assetFiles`. + * this with the live `assetFiles`. Lives in shared-domain so the desktop main + * process (which cannot import the renderer bundle) resolves asset references + * identically when it rewrites them after an asset rename (#785). * * Three readings, tried in order, each matching a way people write links: * 1. Relative to the note's folder, the Markdown link reading. @@ -53,11 +55,24 @@ export function posixNormalize(input: string): string { return out.join('/') } -export function resolveAssetPathAmong( +/** Which of the three readings resolved a reference. Lets a rewrite keep the + * author's style: a note-relative href stays relative, a vault-root path stays + * rooted, a bare file name stays bare (#785). */ +export type AssetReferenceReading = 'note-relative' | 'vault-root' | 'basename' + +export interface AssetReferenceResolution { + /** Vault-relative path of the asset the reference points at. */ + path: string + reading: AssetReferenceReading + /** The reference was written with a leading `/` (vault-root, spelled out). */ + absolute: boolean +} + +export function resolveAssetReference( assets: ReadonlyArray, notePath: string, href: string -): string | null { +): AssetReferenceResolution | null { const trimmed = href.trim() if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('//')) return null if (/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(trimmed)) return null @@ -73,7 +88,13 @@ export function resolveAssetPathAmong( target = posixNormalize(target) if (target.startsWith('../') || target === '..') return null - if (assets.some((asset) => asset.path === target)) return target + if (assets.some((asset) => asset.path === target)) { + return { + path: target, + reading: isAbsolute || !noteDir ? 'vault-root' : 'note-relative', + absolute: isAbsolute + } + } if (!isAbsolute && noteDir) { const rootTarget = posixNormalize(decodedHref) @@ -84,7 +105,7 @@ export function resolveAssetPathAmong( rootTarget !== '..' && assets.some((asset) => asset.path === rootTarget) ) { - return rootTarget + return { path: rootTarget, reading: 'vault-root', absolute: false } } } @@ -96,8 +117,16 @@ export function resolveAssetPathAmong( return assetBase === targetBase }) if (basenameMatches.length === 1) { - return basenameMatches[0]!.path + return { path: basenameMatches[0]!.path, reading: 'basename', absolute: false } } return null } + +export function resolveAssetPathAmong( + assets: ReadonlyArray, + notePath: string, + href: string +): string | null { + return resolveAssetReference(assets, notePath, href)?.path ?? null +} From 6b565e406b3194c229029cd2a82b8369c6d39cf0 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 15 Sep 2026 18:16:22 -0500 Subject: [PATCH 4/6] Feat(templates): live {{modified_date}} tokens that follow the note's last save (#784) {{date}} freezes the creation date into the note. Three new variables stay in the note on purpose and keep reading its modification time: {{modified_date}}, {{modified_time}} and {{modified_datetime}}, each optionally with the same :FORMAT tokens {{date:FORMAT}} takes. The editor's live preview renders them as the file's last-saved time (revealing the raw token under the caret, leaving code literal, and refreshing when the note list's updatedAt moves), and the reading view substitutes them before rendering. renderTemplate leaves them untouched, which the unknown-token pass-through already did; the template editor offers them after {{. The raw file keeps the token, so an "Updated:" line in frontmatter stays current without anyone editing it. Help text documents the three tokens and, from the previous commit, asset rename and move link rewriting. Closes #784 --- .../app-core/src/components/EditorPane.tsx | 3 + packages/app-core/src/components/Preview.tsx | 11 +- .../src/lib/cm-live-template-tokens.test.ts | 84 +++++++++++ .../src/lib/cm-live-template-tokens.ts | 137 ++++++++++++++++++ .../app-core/src/lib/cm-template-variables.ts | 15 ++ packages/app-core/src/lib/help.ts | 6 +- .../src/lib/live-template-tokens.test.ts | 33 +++++ .../app-core/src/lib/live-template-tokens.ts | 67 +++++++++ packages/app-core/src/lib/template-render.ts | 6 +- packages/app-core/src/styles/index.css | 14 ++ 10 files changed, 371 insertions(+), 5 deletions(-) create mode 100644 packages/app-core/src/lib/cm-live-template-tokens.test.ts create mode 100644 packages/app-core/src/lib/cm-live-template-tokens.ts create mode 100644 packages/app-core/src/lib/live-template-tokens.test.ts create mode 100644 packages/app-core/src/lib/live-template-tokens.ts diff --git a/packages/app-core/src/components/EditorPane.tsx b/packages/app-core/src/components/EditorPane.tsx index 32889abe..c5fc9aaa 100644 --- a/packages/app-core/src/components/EditorPane.tsx +++ b/packages/app-core/src/components/EditorPane.tsx @@ -106,6 +106,7 @@ import { tablePlugin, tableVimEntry } from '../lib/cm-table' import { wysiwygBlocksPlugin } from '../lib/cm-wysiwyg-blocks' import { hashtagExtension } from '../lib/cm-hashtags' import { taskMetadataExtension } from '../lib/cm-task-metadata' +import { liveTemplateTokenExtension } from '../lib/cm-live-template-tokens' import { taskRollupExtension } from '../lib/cm-task-rollup' import { hashtagSource } from '../lib/cm-hashtag-complete' import { frontmatterTagSource } from '../lib/cm-frontmatter-tag-complete' @@ -440,6 +441,8 @@ function wysiwygExtensions( ...hashtagExtension, ...taskMetadataExtension, ...taskRollupExtension, + // `{{modified_date}}` and friends read as the note's last-saved time (#784). + ...liveTemplateTokenExtension, ...highlightExtension, ...wikilinkRenderExtension, mathRenderExtension(mathRenderer, typstPreamble), diff --git a/packages/app-core/src/components/Preview.tsx b/packages/app-core/src/components/Preview.tsx index 0eed070e..31315427 100644 --- a/packages/app-core/src/components/Preview.tsx +++ b/packages/app-core/src/components/Preview.tsx @@ -4,6 +4,7 @@ import { createPortal } from "react-dom"; import { createRoot, type Root } from "react-dom/client"; import type { NoteMeta } from "@shared/ipc"; import { renderMarkdown } from "../lib/markdown"; +import { substituteLiveTokens } from "../lib/live-template-tokens"; import { setMarkdownLooseMathDelimiters, setMarkdownMathRenderer, @@ -322,17 +323,25 @@ export const Preview = memo(function Preview({ const embedsReadyRef = useRef(embedsReady); embedsReadyRef.current = embedsReady; + // The note's last-saved time drives the live `{{modified_date}}` tokens (#784). + const noteUpdatedAt = useMemo( + () => notes.find((note) => note.path === notePath)?.updatedAt ?? null, + [notes, notePath], + ); const html = useMemo(() => { // Point the pipeline at the active engine before rendering, so a toggle // takes effect on the very next render without an effect-ordering race. setMarkdownMathRenderer(mathRenderer); setMarkdownLooseMathDelimiters(looseMathDelimiters); - return renderMarkdown(expandedForCurrent ?? markdown); + return renderMarkdown( + substituteLiveTokens(expandedForCurrent ?? markdown, noteUpdatedAt), + ); // customCodeLanguagesRevision re-renders when a grammar is installed, // toggled, or removed; renderMarkdown keys its cache on it too. }, [ expandedForCurrent, markdown, + noteUpdatedAt, mathRenderer, looseMathDelimiters, customCodeLanguagesRevision, diff --git a/packages/app-core/src/lib/cm-live-template-tokens.test.ts b/packages/app-core/src/lib/cm-live-template-tokens.test.ts new file mode 100644 index 00000000..9de5a242 --- /dev/null +++ b/packages/app-core/src/lib/cm-live-template-tokens.test.ts @@ -0,0 +1,84 @@ +// @vitest-environment jsdom + +import { markdown, markdownLanguage } from '@codemirror/lang-markdown' +import { forceParsing } from '@codemirror/language' +import { EditorState } from '@codemirror/state' +import { EditorView } from '@codemirror/view' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { liveTemplateTokenExtension } from './cm-live-template-tokens' + +const savedAt = new Date(2026, 7, 25, 14, 7).getTime() + +const storeState = vi.hoisted(() => ({ + activeNote: { path: 'inbox/Evergreen.md', updatedAt: 0 } as { path: string; updatedAt: number } | null, + notes: [{ path: 'inbox/Evergreen.md', updatedAt: 0 }] as Array<{ path: string; updatedAt: number }> +})) +const listeners = vi.hoisted(() => new Set<(state: unknown, prev: unknown) => void>()) + +vi.mock('../store', () => { + const useStore = Object.assign(() => null, { + getState: () => storeState, + subscribe: (fn: (state: unknown, prev: unknown) => void) => { + listeners.add(fn) + return () => listeners.delete(fn) + } + }) + return { useStore } +}) + +function mount(doc: string, anchor: number): EditorView { + const parent = document.createElement('div') + document.body.append(parent) + const view = new EditorView({ + parent, + state: EditorState.create({ + doc, + selection: { anchor }, + extensions: [markdown({ base: markdownLanguage }), liveTemplateTokenExtension] + }) + }) + forceParsing(view, doc.length, 5000) + view.dispatch({ changes: { from: doc.length, insert: ' ' } }) + view.dispatch({ changes: { from: doc.length, to: doc.length + 1 } }) + return view +} + +const views: EditorView[] = [] +afterEach(() => { + for (const view of views.splice(0)) view.destroy() + storeState.notes = [{ path: 'inbox/Evergreen.md', updatedAt: 0 }] + storeState.activeNote = { path: 'inbox/Evergreen.md', updatedAt: 0 } +}) + +describe('liveTemplateTokenExtension (#784)', () => { + it('renders the token as the note\'s last-saved date when the caret is elsewhere', () => { + storeState.notes = [{ path: 'inbox/Evergreen.md', updatedAt: savedAt }] + const doc = 'Updated: {{modified_date}} ({{modified_time}})' + const view = mount(doc, 0) + views.push(view) + const tokens = Array.from(view.dom.querySelectorAll('.cm-live-token')) + expect(tokens.map((el) => el.textContent)).toEqual(['2026-08-25', '14:07']) + expect(view.dom.textContent).not.toContain('{{modified_date}}') + }) + + it('reveals the raw token under the caret and inside code', () => { + storeState.notes = [{ path: 'inbox/Evergreen.md', updatedAt: savedAt }] + const doc = 'Updated: {{modified_date}} and `{{modified_date}}`' + const view = mount(doc, doc.indexOf('modified')) + views.push(view) + expect(view.dom.querySelector('.cm-live-token')).toBeNull() + expect(view.dom.textContent).toContain('{{modified_date}}') + }) + + it('shows nothing special until the note has a modification time, then refreshes on save', () => { + const doc = 'Updated: {{modified_date}}' + const view = mount(doc, 0) + views.push(view) + expect(view.dom.querySelector('.cm-live-token')).toBeNull() + + const prev = { ...storeState } + storeState.notes = [{ path: 'inbox/Evergreen.md', updatedAt: savedAt }] + for (const fn of listeners) fn(storeState, prev) + expect(view.dom.querySelector('.cm-live-token')?.textContent).toBe('2026-08-25') + }) +}) diff --git a/packages/app-core/src/lib/cm-live-template-tokens.ts b/packages/app-core/src/lib/cm-live-template-tokens.ts new file mode 100644 index 00000000..c03fd1c2 --- /dev/null +++ b/packages/app-core/src/lib/cm-live-template-tokens.ts @@ -0,0 +1,137 @@ +/** + * Live preview for the live template tokens (#784): `{{modified_date}}`, + * `{{modified_time}}` and `{{modified_datetime}}` render as the note's + * last-saved time wherever the caret is not, and come back as the raw token + * when the selection touches them so they can be edited or removed. Tokens + * inside code stay literal, like every other live-preview decoration. + */ +import { syntaxTree } from '@codemirror/language' +import { RangeSetBuilder, StateEffect } from '@codemirror/state' +import { + Decoration, + type DecorationSet, + EditorView, + ViewPlugin, + type ViewUpdate, + WidgetType +} from '@codemirror/view' +import { useStore } from '../store' +import { LIVE_TOKEN_RE, formatLiveToken, liveTokenFromMatch } from './live-template-tokens' + +class LiveTokenWidget extends WidgetType { + constructor( + private readonly text: string, + private readonly raw: string + ) { + super() + } + eq(other: LiveTokenWidget): boolean { + return other.text === this.text && other.raw === this.raw + } + toDOM(): HTMLElement { + const span = document.createElement('span') + span.className = 'cm-live-token' + span.textContent = this.text + span.title = `Last modified (${this.raw})` + return span + } + ignoreEvent(): boolean { + return false + } +} + +function isInsideCode(state: EditorView['state'], pos: number): boolean { + let node = syntaxTree(state).resolveInner(pos, 1) + while (node) { + const n = node.name + if (n === 'FencedCode' || n === 'CodeBlock' || n === 'InlineCode') return true + if (!node.parent) break + node = node.parent + } + return false +} + +function selectionTouches(state: EditorView['state'], from: number, to: number): boolean { + for (const range of state.selection.ranges) { + if (range.empty) { + if (range.from >= from && range.from <= to) return true + } else if (Math.max(range.from, from) < Math.min(range.to, to)) { + return true + } + } + return false +} + +/** + * The note's last-saved time. The listing entry is the source of truth (the + * watcher refreshes it after every save); the open note's own meta is the + * fallback until the listing has caught up. + */ +function activeNoteModified(): Date | null { + const state = useStore.getState() + const path = state.activeNote?.path + if (!path) return null + const listed = state.notes.find((note) => note.path === path)?.updatedAt ?? 0 + const at = Math.max(listed, state.activeNote?.updatedAt ?? 0) + return at > 0 ? new Date(at) : null +} + +function buildDecorations(view: EditorView): DecorationSet { + const builder = new RangeSetBuilder() + const { state } = view + const modified = activeNoteModified() + if (!modified) return builder.finish() + for (const { from, to } of view.visibleRanges) { + const firstLine = state.doc.lineAt(from).number + const lastLine = state.doc.lineAt(Math.max(from, to - 1)).number + for (let n = firstLine; n <= lastLine; n++) { + const line = state.doc.line(n) + if (!line.text.includes('{{')) continue + LIVE_TOKEN_RE.lastIndex = 0 + let m: RegExpExecArray | null + while ((m = LIVE_TOKEN_RE.exec(line.text)) !== null) { + const start = line.from + m.index + const end = start + m[0].length + if (isInsideCode(state, start + 2)) continue + if (selectionTouches(state, start, end)) continue + const text = formatLiveToken(liveTokenFromMatch(m[1], m[2]), modified) + builder.add(start, end, Decoration.replace({ widget: new LiveTokenWidget(text, m[0]) })) + } + } + } + return builder.finish() +} + +const refreshLiveTokensEffect = StateEffect.define() + +const liveTemplateTokenPlugin = ViewPlugin.fromClass( + class { + decorations: DecorationSet + unsubscribe: (() => void) | null = null + constructor(view: EditorView) { + this.decorations = buildDecorations(view) + // The value moves under the editor with every save (the listing's + // `updatedAt`) and with the active note itself. + this.unsubscribe = useStore.subscribe((state, prev) => { + if (state.notes !== prev.notes || state.activeNote !== prev.activeNote) { + view.dispatch({ effects: refreshLiveTokensEffect.of(null) }) + } + }) + } + update(update: ViewUpdate): void { + const refreshed = update.transactions.some((tr) => + tr.effects.some((effect) => effect.is(refreshLiveTokensEffect)) + ) + if (refreshed || update.docChanged || update.selectionSet || update.viewportChanged) { + this.decorations = buildDecorations(update.view) + } + } + destroy(): void { + this.unsubscribe?.() + this.unsubscribe = null + } + }, + { decorations: (plugin) => plugin.decorations } +) + +export const liveTemplateTokenExtension = [liveTemplateTokenPlugin] diff --git a/packages/app-core/src/lib/cm-template-variables.ts b/packages/app-core/src/lib/cm-template-variables.ts index 7971603c..deefd6e2 100644 --- a/packages/app-core/src/lib/cm-template-variables.ts +++ b/packages/app-core/src/lib/cm-template-variables.ts @@ -20,6 +20,21 @@ export const TEMPLATE_VARIABLES: TemplateVariable[] = [ }, { name: 'time', insert: '{{time}}', detail: 'Current time (HH:mm)' }, { name: 'week', insert: '{{week}}', detail: 'ISO week number' }, + { + name: 'modified_date', + insert: '{{modified_date}}', + detail: 'Live: the date the note was last saved (stays in the note, always current)' + }, + { + name: 'modified_time', + insert: '{{modified_time}}', + detail: 'Live: the time the note was last saved (HH:mm)' + }, + { + name: 'modified_datetime', + insert: '{{modified_datetime}}', + detail: 'Live: date and time the note was last saved' + }, { name: 'cursor', insert: '{{cursor}}', detail: 'Where the caret lands' } ] diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index b989396f..f321cd0d 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -109,7 +109,7 @@ export const HELP_HOW_TO_GUIDES: HelpCard[] = [ { title: 'Make and edit your own templates', body: - 'Open Settings → Templates. Press "New template" to author one: a template is just markdown with optional YAML frontmatter (`name`, `description`, `category`, `titleTemplate`, `targetFolder`, `targetSubpath`) and a body. Use the variables `{{title}}`, `{{date}}`, `{{date:FORMAT}}` (e.g. `{{date:YYYY-MM-DD}}`), `{{time}}`, `{{week}}`, and `{{cursor}}` (where the caret lands). The date format accepts the same tokens as the daily/weekly note directory and title patterns — `yyyy`/`yy`, `MMMM`/`MMM`/`MM`/`M`, `dd`/`d`, `EEEE`/`EEE` (weekday), `ww`/`w` (ISO week) — as well as moment-style `YYYY`/`DD`/`dddd`; wrap literal letters in `[brackets]`. Custom templates are saved as plain `.md` files under `.zennotes/templates/`. You can also fork a built-in by pressing Edit on it — that creates an editable copy that shadows the original, and Reset restores the built-in. From any note, the "Save Current Note as Template…" command captures it as a new template.' + 'Open Settings → Templates. Press "New template" to author one: a template is just markdown with optional YAML frontmatter (`name`, `description`, `category`, `titleTemplate`, `targetFolder`, `targetSubpath`) and a body. Use the variables `{{title}}`, `{{date}}`, `{{date:FORMAT}}` (e.g. `{{date:YYYY-MM-DD}}`), `{{time}}`, `{{week}}`, and `{{cursor}}` (where the caret lands). Three more stay live instead of being substituted: `{{modified_date}}`, `{{modified_time}}` and `{{modified_datetime}}` are left in the note and always show when the file was last saved, so an `Updated:` line keeps itself current; `{{modified_date:FORMAT}}` takes the same date tokens. The date format accepts the same tokens as the daily/weekly note directory and title patterns — `yyyy`/`yy`, `MMMM`/`MMM`/`MM`/`M`, `dd`/`d`, `EEEE`/`EEE` (weekday), `ww`/`w` (ISO week) — as well as moment-style `YYYY`/`DD`/`dddd`; wrap literal letters in `[brackets]`. Custom templates are saved as plain `.md` files under `.zennotes/templates/`. You can also fork a built-in by pressing Edit on it — that creates an editable copy that shadows the original, and Reset restores the built-in. From any note, the "Save Current Note as Template…" command captures it as a new template.' }, { title: 'Draw diagrams with Excalidraw', @@ -382,7 +382,7 @@ export const HELP_CORE_CONCEPTS: HelpCard[] = [ { title: 'Templates scaffold new notes', body: - 'Templates turn a repeated note shape into one keystroke. ZenNotes ships built-in templates for engineering (ADR, RFC, Bug Report, Postmortem, Meeting Notes, 1:1) and personal use (Daily Note, Weekly Review, Reading Notes, Journal, Project Kickoff, To-do), and you can author your own under Settings → Templates. A template is plain markdown with optional frontmatter and variables — `{{title}}`, `{{date}}`, `{{date:FORMAT}}`, `{{time}}`, `{{week}}`, and `{{cursor}}` — substituted at creation time. Custom templates are stored as `.md` files in `.zennotes/templates/`, so they stay portable like everything else. Daily and weekly notes can each be assigned a template so dated notes start pre-filled.' + 'Templates turn a repeated note shape into one keystroke. ZenNotes ships built-in templates for engineering (ADR, RFC, Bug Report, Postmortem, Meeting Notes, 1:1) and personal use (Daily Note, Weekly Review, Reading Notes, Journal, Project Kickoff, To-do), and you can author your own under Settings → Templates. A template is plain markdown with optional frontmatter and variables — `{{title}}`, `{{date}}`, `{{date:FORMAT}}`, `{{time}}`, `{{week}}`, and `{{cursor}}` — substituted at creation time, plus the live `{{modified_date}}`, `{{modified_time}}` and `{{modified_datetime}}`, which stay in the note and show its last-saved time. Custom templates are stored as `.md` files in `.zennotes/templates/`, so they stay portable like everything else. Daily and weekly notes can each be assigned a template so dated notes start pre-filled.' }, { title: 'Reference and connections support research-heavy work', @@ -1135,7 +1135,7 @@ export const HELP_SETTINGS: HelpSettingsSection[] = [ title: 'Templates', items: [ { label: 'Template library', detail: 'Browse every template — built-in and custom. Built-ins cover engineering (ADR, RFC, Bug Report, Postmortem, Meeting Notes, 1:1) and personal use (Daily Note, Weekly Review, Reading Notes, Journal, Project Kickoff, To-do).' }, - { label: 'Create a custom template', detail: 'Author a new template as markdown with optional frontmatter (`name`, `description`, `category`, `titleTemplate`, `targetFolder`, `targetSubpath`) and variables like `{{title}}`, `{{date}}`, `{{date:FORMAT}}`, `{{time}}`, `{{week}}`, and `{{cursor}}`. It is saved as a `.md` file in `.zennotes/templates/`.' }, + { label: 'Create a custom template', detail: 'Author a new template as markdown with optional frontmatter (`name`, `description`, `category`, `titleTemplate`, `targetFolder`, `targetSubpath`) and variables like `{{title}}`, `{{date}}`, `{{date:FORMAT}}`, `{{time}}`, `{{week}}`, `{{cursor}}`, and the live `{{modified_date}}` / `{{modified_time}}` / `{{modified_datetime}}` (rendered from the note’s last-saved time rather than substituted). It is saved as a `.md` file in `.zennotes/templates/`.' }, { label: 'Edit or reset built-ins', detail: 'Press Edit on a built-in to fork an editable copy that shadows the original everywhere; Reset removes the copy and restores the built-in. Custom templates can be edited or deleted directly.' }, { label: 'Remove or restore built-ins', detail: 'Hide all the shipped templates with “Remove Built-in Templates” (a button here, or the command palette; it asks first), and bring them back with “Restore Built-in Templates”. Your custom templates, and anything already pointing at a built-in by id, keep working.' }, { label: 'Where templates appear', detail: 'Use a template via the picker (`Space t` / `:template` / “New Note from Template…”), from a folder’s right-click “New from template”, or as the assigned daily/weekly note template. Custom templates work on a local vault, in the self-hosted web client, and on a remote vault served by ZenNotes server 2.46 or later; they are `.md` files in the vault’s `.zennotes/templates/`, so one saved in any client shows up in the others. Built-ins work everywhere.' } diff --git a/packages/app-core/src/lib/live-template-tokens.test.ts b/packages/app-core/src/lib/live-template-tokens.test.ts new file mode 100644 index 00000000..3d8f7bfa --- /dev/null +++ b/packages/app-core/src/lib/live-template-tokens.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { formatLiveToken, substituteLiveTokens } from './live-template-tokens' + +// 2026-08-25 14:07:09 local time. +const saved = new Date(2026, 7, 25, 14, 7, 9).getTime() + +describe('live template tokens (#784)', () => { + it('renders the three kinds from the note\'s last-saved time', () => { + expect(substituteLiveTokens('Updated: {{modified_date}}', saved)).toBe('Updated: 2026-08-25') + expect(substituteLiveTokens('At {{modified_time}}', saved)).toBe('At 14:07') + expect(substituteLiveTokens('{{ modified_datetime }}', saved)).toBe('2026-08-25 14:07') + }) + + it('takes the same FORMAT tokens as {{date:FORMAT}}', () => { + expect(substituteLiveTokens('{{modified_date:DD/MM/YYYY}}', saved)).toBe('25/08/2026') + expect(substituteLiveTokens('{{modified_datetime:yyyy-MM-dd HH:mm:ss}}', saved)).toBe( + '2026-08-25 14:07:09' + ) + expect(formatLiveToken({ kind: 'modified_time', format: 'HH[h]mm' }, new Date(saved))).toBe('14h07') + }) + + it('leaves tokens alone inside code and when no modification time is known', () => { + const body = 'Use `{{modified_date}}` like so:\n```\n{{modified_time}}\n```\n' + expect(substituteLiveTokens(body, saved)).toBe(body) + expect(substituteLiveTokens('Updated: {{modified_date}}', null)).toBe('Updated: {{modified_date}}') + expect(substituteLiveTokens('Updated: {{modified_date}}', 0)).toBe('Updated: {{modified_date}}') + }) + + it('does not touch the creation-time variables or unknown tokens', () => { + const body = 'Created: {{date}} {{time}} {{title}} {{unknown}} {{modified}}' + expect(substituteLiveTokens(body, saved)).toBe(body) + }) +}) diff --git a/packages/app-core/src/lib/live-template-tokens.ts b/packages/app-core/src/lib/live-template-tokens.ts new file mode 100644 index 00000000..50b8d9ef --- /dev/null +++ b/packages/app-core/src/lib/live-template-tokens.ts @@ -0,0 +1,67 @@ +/** + * Live template tokens (#784). + * + * `{{modified_date}}`, `{{modified_time}}` and `{{modified_datetime}}` (each + * optionally `:FORMAT`, with the same tokens `{{date:FORMAT}}` takes) are the + * one family of template variables that `renderTemplate` deliberately leaves + * in the note. Where `{{date}}` freezes the creation date into the text, these + * keep reading the note's file modification time, so an `Updated:` line stays + * true without anyone editing it. The editor (live preview) and the reading + * view render them from the note's `updatedAt`; the raw markdown, and any other + * reader of the file, sees the token itself. + */ +import { formatDate, formatISODate, formatTime } from './template-render' + +export type LiveTokenKind = 'modified_date' | 'modified_time' | 'modified_datetime' + +export interface LiveToken { + kind: LiveTokenKind + /** Custom `formatDate` pattern from `{{modified_date:FORMAT}}`, or null. */ + format: string | null +} + +const TOKEN_BODY = String.raw`\{\{\s*(modified_date|modified_time|modified_datetime)(?::([^}]*?))?\s*\}\}` + +/** Global matcher for live tokens; group 1 is the kind, group 2 the format. */ +export const LIVE_TOKEN_RE = new RegExp(TOKEN_BODY, 'g') + +// Code first, so a token documented inside a span or fence stays literal. +const CODE_OR_TOKEN_RE = new RegExp( + String.raw`(${'```'}[\s\S]*?${'```'}|~~~[\s\S]*?~~~|${'`'}[^${'`'}\n]*${'`'})|${TOKEN_BODY}`, + 'g' +) + +export function liveTokenFromMatch(kind: string, format: string | undefined): LiveToken { + const trimmed = format?.trim() + return { kind: kind as LiveTokenKind, format: trimmed ? trimmed : null } +} + +/** The text a live token shows for a note last saved at `modified`. */ +export function formatLiveToken(token: LiveToken, modified: Date): string { + if (token.format) return formatDate(modified, token.format) + switch (token.kind) { + case 'modified_time': + return formatTime(modified) + case 'modified_datetime': + return `${formatISODate(modified)} ${formatTime(modified)}` + default: + return formatISODate(modified) + } +} + +/** + * Expand the live tokens in `markdown` for a rendered, read-only surface (the + * reading view, exports). `updatedAt` is the note's modification time in ms; + * without one the tokens are left as written. Code is skipped. + */ +export function substituteLiveTokens(markdown: string, updatedAt: number | null | undefined): string { + if (!updatedAt || !markdown.includes('{{')) return markdown + const modified = new Date(updatedAt) + return markdown.replace( + CODE_OR_TOKEN_RE, + (full: string, code: string | undefined, kind: string | undefined, format: string | undefined) => { + if (code !== undefined || kind === undefined) return full + return formatLiveToken(liveTokenFromMatch(kind, format), modified) + } + ) +} diff --git a/packages/app-core/src/lib/template-render.ts b/packages/app-core/src/lib/template-render.ts index ae6e9684..428dac44 100644 --- a/packages/app-core/src/lib/template-render.ts +++ b/packages/app-core/src/lib/template-render.ts @@ -12,6 +12,10 @@ * {{cursor}} removed from output; marks where the caret should land * * Unknown `{{tokens}}` are passed through unchanged so user braces survive. + * That is also how the live tokens work: `{{modified_date}}`, + * `{{modified_time}}` and `{{modified_datetime}}` stay in the note on purpose + * and are rendered from its modification time by the editor and reading view + * (see live-template-tokens.ts, #784). */ export interface TemplateContext { @@ -54,7 +58,7 @@ export function formatISODate(date: Date): string { return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}` } -function formatTime(date: Date): string { +export function formatTime(date: Date): string { return `${pad2(date.getHours())}:${pad2(date.getMinutes())}` } diff --git a/packages/app-core/src/styles/index.css b/packages/app-core/src/styles/index.css index 229fd1ab..36a51a7f 100644 --- a/packages/app-core/src/styles/index.css +++ b/packages/app-core/src/styles/index.css @@ -5555,6 +5555,20 @@ html[data-completed-task-style="gray-strikethrough"] .prose-zen li.task-list-ite .cm-wysiwyg .cm-editor .cm-wikilink.cm-wikilink-broken:hover { text-decoration-color: theme("colors.ink.500"); } +/* Live template tokens (#784): `{{modified_date}}` and friends render as the + note's last-saved time. The dotted underline says "computed, not typed"; + click or move the caret onto it to see and edit the token itself. */ +.cm-wysiwyg .cm-editor .cm-live-token { + color: theme("colors.ink.700"); + text-decoration: underline dotted; + text-decoration-color: theme("colors.ink.400"); + text-underline-offset: 2px; + cursor: text; +} +.dark .cm-wysiwyg .cm-editor .cm-live-token { + color: theme("colors.ink.300"); + text-decoration-color: theme("colors.ink.600"); +} /* Revealed `[[ ]]` / `|` markers when editing a wikilink — quiet grey, upright, no underline (the inner brackets otherwise inherit the italic/underline link highlight, which left one bracket slanted). */ From e73e4a4ba16c83470ab530bfabe0e1ccf232d0b6 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 15 Sep 2026 18:16:22 -0500 Subject: [PATCH 5/6] Fix(preview): open the asset menu on images in the reading view The reading view's right-click asset menu (Open, Rename, Move, Duplicate, Copy as Embed, Copy Path, Open as Reference, Reveal, Delete) finds its host through [data-local-asset-kind][data-local-asset-url]. PDF, audio and video embeds and attachment chips carried both since 1.1.0; the image figure only stamped the URL on the , so an image was the one embed with no menu. Tag the figure like the others. --- .../src/lib/local-assets-image-menu.test.ts | 51 +++++++++++++++++++ packages/app-core/src/lib/local-assets.ts | 8 +++ 2 files changed, 59 insertions(+) create mode 100644 packages/app-core/src/lib/local-assets-image-menu.test.ts diff --git a/packages/app-core/src/lib/local-assets-image-menu.test.ts b/packages/app-core/src/lib/local-assets-image-menu.test.ts new file mode 100644 index 00000000..c0a4b2c2 --- /dev/null +++ b/packages/app-core/src/lib/local-assets-image-menu.test.ts @@ -0,0 +1,51 @@ +// @vitest-environment jsdom + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// The reading view's right-click asset menu (Open, Rename…, Move…, Reveal, +// Delete…) finds its host through `[data-local-asset-kind][data-local-asset-url]`. +// PDF / audio / video embeds and attachment chips carried both since day one; +// image figures only stamped the URL on the ``, so an image was the one +// embed with no menu. + +function installZen(): void { + Object.defineProperty(window, 'zen', { + configurable: true, + value: { + resolveLocalAssetUrl: vi.fn((_r: string, _n: string, href: string) => `zen-asset://v/${href}`), + resolveVaultAssetUrl: vi.fn((_r: string, rel: string) => `zen-asset://v/${rel}`) + } + }) +} + +async function load() { + vi.resetModules() + localStorage.clear() + installZen() + const { useStore } = await import('../store') + const { enhanceLocalAssetNodes } = await import('./local-assets') + return { useStore, enhanceLocalAssetNodes } +} + +beforeEach(() => { + vi.restoreAllMocks() +}) + +describe('image embeds carry the asset-menu host attributes', () => { + it('tags the image figure with kind, url and href like the other embeds', async () => { + const { useStore, enhanceLocalAssetNodes } = await load() + useStore.setState({ assetFiles: [{ path: 'assets/shot.png' }] } as never) + const root = document.createElement('div') + root.innerHTML = '

shot

' + enhanceLocalAssetNodes(root, { vaultRoot: '/v', notePath: 'inbox/Gallery.md' }) + + const figure = root.querySelector('figure.local-image-embed') + expect(figure).not.toBeNull() + expect(figure!.dataset.localAssetKind).toBe('image') + expect(figure!.dataset.localAssetUrl).toBe('zen-asset://v/assets/shot.png') + expect(figure!.dataset.localAssetHref).toBe('assets/shot.png') + // The selector the Preview's context-menu handler walks up to. + const img = figure!.querySelector('img')! + expect(img.closest('[data-local-asset-kind][data-local-asset-url]')).toBe(figure) + }) +}) diff --git a/packages/app-core/src/lib/local-assets.ts b/packages/app-core/src/lib/local-assets.ts index 44d3cc0c..2b50f781 100644 --- a/packages/app-core/src/lib/local-assets.ts +++ b/packages/app-core/src/lib/local-assets.ts @@ -136,6 +136,14 @@ function buildImageEmbed( ): HTMLElement { const figure = document.createElement('figure') figure.className = 'local-image-embed not-prose' + // Tag the host the way PDF / audio / video embeds and attachment chips are, + // so the reading view's right-click asset menu (Open, Rename…, Move…, + // Reveal, Delete…) finds an image too. Only the `` carried the URL + // before, and the menu handler looks for kind + url on one host, so images + // were the one embed with no menu. + figure.dataset.localAssetUrl = resolvedUrl + figure.dataset.localAssetKind = 'image' + figure.dataset.localAssetHref = rawHref const frame = document.createElement('div') frame.className = 'local-image-embed-frame' From 850cf5f8a7f7e10d3f47df1c5732209d8e0c2dcc Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 15 Sep 2026 18:22:49 -0500 Subject: [PATCH 6/6] Release: align desktop and shared packages at 2.50.4 --- apps/desktop/package.json | 2 +- apps/server/package.json | 2 +- apps/web/package.json | 2 +- package-lock.json | 18 +++++++++--------- package.json | 2 +- packages/app-core/package.json | 2 +- packages/bridge-contract/package.json | 2 +- packages/shared-domain/package.json | 2 +- packages/shared-ui/package.json | 2 +- 9 files changed, 17 insertions(+), 17 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 8a554f09..b459fbe3 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/desktop", "productName": "ZenNotes", - "version": "2.50.3", + "version": "2.50.4", "description": "ZenNotes desktop shell", "private": true, "main": "./out/main/index.js", diff --git a/apps/server/package.json b/apps/server/package.json index 784ec598..9a5f860a 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/server", "private": true, - "version": "2.50.3", + "version": "2.50.4", "scripts": { "dev": "node ../../tooling/scripts/run-go-server-dev.mjs", "prepare-web": "node ../../tooling/scripts/prepare-server-web-dist.mjs", diff --git a/apps/web/package.json b/apps/web/package.json index d3d11c2c..7c223b35 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/web", "private": true, - "version": "2.50.3", + "version": "2.50.4", "type": "module", "description": "ZenNotes web client for self-hosted and hosted deployments", "homepage": "https://zennotes.org", diff --git a/package-lock.json b/package-lock.json index dad71a5e..5f571790 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "zennotes-monorepo", - "version": "2.50.3", + "version": "2.50.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zennotes-monorepo", - "version": "2.50.3", + "version": "2.50.4", "hasInstallScript": true, "workspaces": [ "apps/*", @@ -23,7 +23,7 @@ }, "apps/desktop": { "name": "@zennotes/desktop", - "version": "2.50.3", + "version": "2.50.4", "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.18.3", @@ -874,11 +874,11 @@ }, "apps/server": { "name": "@zennotes/server", - "version": "2.50.3" + "version": "2.50.4" }, "apps/web": { "name": "@zennotes/web", - "version": "2.50.3", + "version": "2.50.4", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -16286,7 +16286,7 @@ }, "packages/app-core": { "name": "@zennotes/app-core", - "version": "2.50.3", + "version": "2.50.4", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -16363,11 +16363,11 @@ }, "packages/bridge-contract": { "name": "@zennotes/bridge-contract", - "version": "2.50.3" + "version": "2.50.4" }, "packages/shared-domain": { "name": "@zennotes/shared-domain", - "version": "2.50.3", + "version": "2.50.4", "dependencies": { "@zennotes/bridge-contract": "*", "lz-string": "^1.5.0" @@ -16378,7 +16378,7 @@ }, "packages/shared-ui": { "name": "@zennotes/shared-ui", - "version": "2.50.3" + "version": "2.50.4" } } } diff --git a/package.json b/package.json index f270c7f4..9acf805b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "zennotes-monorepo", "private": true, - "version": "2.50.3", + "version": "2.50.4", "description": "ZenNotes monorepo for desktop, web, and self-hosted server builds", "packageManager": "npm@10.9.2", "engines": { diff --git a/packages/app-core/package.json b/packages/app-core/package.json index 2dfbde0f..bf1e194a 100644 --- a/packages/app-core/package.json +++ b/packages/app-core/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/app-core", "private": true, - "version": "2.50.3", + "version": "2.50.4", "type": "module", "exports": { "./main": "./src/main.tsx" diff --git a/packages/bridge-contract/package.json b/packages/bridge-contract/package.json index 33bd530b..ed1abe1f 100644 --- a/packages/bridge-contract/package.json +++ b/packages/bridge-contract/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/bridge-contract", "private": true, - "version": "2.50.3", + "version": "2.50.4", "type": "module", "exports": { "./bridge": "./src/bridge.ts", diff --git a/packages/shared-domain/package.json b/packages/shared-domain/package.json index 24306bf2..704fe806 100644 --- a/packages/shared-domain/package.json +++ b/packages/shared-domain/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/shared-domain", "private": true, - "version": "2.50.3", + "version": "2.50.4", "type": "module", "exports": { "./*": "./src/*.ts" diff --git a/packages/shared-ui/package.json b/packages/shared-ui/package.json index 73860735..8d8a8296 100644 --- a/packages/shared-ui/package.json +++ b/packages/shared-ui/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/shared-ui", "private": true, - "version": "2.50.3", + "version": "2.50.4", "type": "module", "exports": { ".": "./src/index.ts"