Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
78 changes: 78 additions & 0 deletions apps/desktop/src/main/vault.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
70 changes: 67 additions & 3 deletions apps/desktop/src/main/vault.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)])
Expand All @@ -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<void> {
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(
Expand All @@ -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<AssetMeta> {
Expand Down
Loading
Loading