Skip to content

Commit d88d1f2

Browse files
committed
Fix(assets): every device links an attached file the same way
Attaching or dropping a file wrote a different link depending on which surface you did it from. Paste, and an existing asset dragged in from the sidebar, embedded a vault-relative wikilink; a drop wrote a markdown link with a path relative to the note, `![pic](<../assets/pic.png>)`, and the self-hosted server produced that same note-relative form for the web client. The mobile apps had a third answer again. Same file, same folder, three different links. The note-relative form was also wrong, not merely different: nothing rewrites relative asset paths when a note moves, so `../assets/pic.png` broke the moment the note changed depth. So there is now one rule, everywhere: the file lands in `assets/` and is linked by VAULT-relative path, an image as `![[assets/pic.png]]` and anything else as `[name](<assets/report.pdf>)`. That is what paste and the sidebar drag already wrote, so this moves the odd routes onto the form the app already used most. Existing notes are unaffected: resolveAssetVaultRelativePath tries note-relative, then vault-root, then a unique basename, so both the old and the new form keep resolving on both rendering surfaces. `ImportAsset` no longer reads notePath, since where the note lives can no longer change what is written; it stays in the signature because the handler and clients still send it. The matching mobile fixes ship in the zennotesandroid and zennotesios repos. Claude-Session: https://claude.ai/code/session_01AYTRixg5TJmxn2j6FCqfUD
1 parent 2f71229 commit d88d1f2

4 files changed

Lines changed: 64 additions & 35 deletions

File tree

apps/desktop/src/main/vault.test.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -442,7 +442,7 @@ describe('importFiles', () => {
442442
await expect(readFile(path.join(root, 'Diagram.png'))).rejects.toThrow()
443443
})
444444

445-
it('stores in assets/ for an inbox-mode note too, linked relative to the note', async () => {
445+
it('stores in assets/ for an inbox-mode note too, linked by vault-relative wikilink', async () => {
446446
const root = await makeTempDir('zennotes-import-files-inbox-')
447447
await ensureVaultLayout(root)
448448
const srcDir = await makeTempDir('zennotes-import-src-inbox-')
@@ -452,8 +452,9 @@ describe('importFiles', () => {
452452
const imported = await importFiles(root, 'inbox/Note.md', [src])
453453

454454
expect(imported[0]?.path).toBe('assets/Photo.png')
455-
// Note in inbox/, asset in assets/, so the link steps up a level.
456-
expect(imported[0]?.markdown).toContain('../assets/Photo.png')
455+
// A dropped image embeds by vault-relative wikilink, the same form paste
456+
// and the mobile apps write, so the link survives the note being moved.
457+
expect(imported[0]?.markdown).toBe('![[assets/Photo.png]]')
457458
await expect(readFile(path.join(root, 'assets/Photo.png'))).resolves.toEqual(
458459
Buffer.from([1, 2, 3])
459460
)

apps/desktop/src/main/vault.ts

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3405,16 +3405,27 @@ function cleanDeletedAssetToken(token: string): string {
34053405
return token
34063406
}
34073407

3408+
/**
3409+
* The link written for a file dragged or attached into a note, by VAULT-relative
3410+
* path: an image as a wikilink, anything else as a markdown link.
3411+
*
3412+
* This is the one rule every route now follows, on every platform: paste here,
3413+
* an existing asset dragged in from the sidebar
3414+
* (`importedAssetForExistingVaultAsset`), the server's own import, and both
3415+
* mobile apps. A drop used to be the odd one out, writing a path relative to
3416+
* the note, so the same file looked different depending on how it entered the
3417+
* note and the link broke as soon as the note moved to another depth, because
3418+
* nothing rewrites relative asset paths on move. Both forms resolve
3419+
* (`resolveAssetVaultRelativePath` tries note-relative, then vault-root, then a
3420+
* unique basename), so this is safe for links already in existing notes.
3421+
*/
34083422
function markdownForImportedAsset(
3409-
relativeFromNote: string,
3423+
vaultRelPath: string,
34103424
filename: string,
34113425
kind: ImportedAssetKind
34123426
): string {
3413-
const destination = markdownDestination(relativeFromNote)
3414-
if (kind === 'image') {
3415-
return `![${path.basename(filename, path.extname(filename))}](${destination})`
3416-
}
3417-
return `[${filename}](${destination})`
3427+
if (kind === 'image') return `![[${vaultRelPath}]]`
3428+
return `[${filename}](${markdownDestination(vaultRelPath)})`
34183429
}
34193430

34203431
// The naming lives in @shared/pasted-image so the web client produces the
@@ -4261,7 +4272,6 @@ export async function importFiles(
42614272
): Promise<ImportedAsset[]> {
42624273
await fs.mkdir(root, { recursive: true })
42634274

4264-
const noteDir = path.posix.dirname(toPosix(noteRelPath))
42654275
const imported: ImportedAsset[] = []
42664276
// Dropped files land in the unified `assets/` folder, matching pasted images
42674277
// (`importPastedImage`). They used to be copied to the vault root, which in
@@ -4280,15 +4290,11 @@ export async function importFiles(
42804290
await fs.copyFile(sourceAbs, destAbs)
42814291

42824292
const vaultRelPath = toPosix(path.relative(root, destAbs))
4283-
const relativeFromNote = path.posix.relative(
4284-
noteDir === '.' ? '' : noteDir,
4285-
vaultRelPath
4286-
)
42874293
const kind = classifyImportedAsset(finalName)
42884294
imported.push({
42894295
name: finalName,
42904296
path: vaultRelPath,
4291-
markdown: markdownForImportedAsset(relativeFromNote, finalName, kind),
4297+
markdown: markdownForImportedAsset(vaultRelPath, finalName, kind),
42924298
kind
42934299
})
42944300
}

apps/server/internal/vault/vault.go

Lines changed: 15 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2521,7 +2521,11 @@ func (v *Vault) SearchText(query string) ([]TextSearchMatch, error) {
25212521
// mirrors the desktop importFiles/importPastedImage (#377): uploads used to
25222522
// land at the vault root, which in Vault Root mode dumped them right next to
25232523
// the notes.
2524+
// notePath is no longer read: the link is vault-relative now, so where the note
2525+
// lives does not change what gets written. It stays in the signature because
2526+
// the HTTP handler and clients still send it.
25242527
func (v *Vault) ImportAsset(notePath, filename string, body io.Reader) (ImportedAsset, error) {
2528+
_ = notePath
25252529
v.mu.Lock()
25262530
defer v.mu.Unlock()
25272531
assetsAbs := filepath.Join(v.root, AssetsDir)
@@ -2562,18 +2566,8 @@ func (v *Vault) ImportAsset(notePath, filename string, body io.Reader) (Imported
25622566
return ImportedAsset{}, err
25632567
}
25642568
rel := filepath.ToSlash(relFromRoot)
2565-
noteDir := filepath.Dir(filepath.FromSlash(notePath))
2566-
if noteDir == "." {
2567-
noteDir = ""
2568-
}
2569-
markdownPath := rel
2570-
if noteDir != "" {
2571-
if relative, err := filepath.Rel(noteDir, rel); err == nil {
2572-
markdownPath = filepath.ToSlash(relative)
2573-
}
2574-
}
25752569
kind := kindForExt(strings.ToLower(filepath.Ext(abs)))
2576-
markdown := makeAssetMarkdown(markdownPath, kind, filepath.Base(abs))
2570+
markdown := makeAssetMarkdown(rel, kind, filepath.Base(abs))
25772571
return ImportedAsset{
25782572
Name: filepath.Base(abs),
25792573
Path: rel,
@@ -2740,14 +2734,17 @@ func cleanAssetFilename(name string) (string, error) {
27402734
return trimmed, nil
27412735
}
27422736

2743-
func makeAssetMarkdown(relPath, kind, name string) string {
2744-
dest := "<" + strings.ReplaceAll(relPath, ">", "%3E") + ">"
2745-
switch kind {
2746-
case "image":
2747-
return "![" + name + "](" + dest + ")"
2748-
default:
2749-
return "[" + name + "](" + dest + ")"
2737+
// makeAssetMarkdown mirrors the desktop markdownForImportedAsset: everything is
2738+
// linked by VAULT-relative path, an image as a wikilink and anything else as a
2739+
// markdown link, which is the single form every client now writes. The link
2740+
// used to be relative to the note, so it broke as soon as the note moved to
2741+
// another depth (nothing rewrites relative asset paths on move).
2742+
func makeAssetMarkdown(vaultRelPath, kind, name string) string {
2743+
if kind == "image" {
2744+
return "![[" + vaultRelPath + "]]"
27502745
}
2746+
dest := "<" + strings.ReplaceAll(vaultRelPath, ">", "%3E") + ">"
2747+
return "[" + name + "](" + dest + ")"
27512748
}
27522749

27532750
// --- Misc helpers ---

apps/server/internal/vault/vault_test.go

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,13 +111,38 @@ func TestImportAssetWithinLimit(t *testing.T) {
111111
if !bytes.Equal(got, body) {
112112
t.Fatalf("written bytes differ from input")
113113
}
114-
// The embed markdown is relative to the note's directory: a root-level
115-
// note links straight into assets/.
114+
// A non-image keeps the note-relative markdown link: a root-level note
115+
// links straight into assets/.
116116
if asset.Markdown == "" || !strings.Contains(asset.Markdown, "assets/x.bin") {
117117
t.Fatalf("markdown = %q, want a link into assets/", asset.Markdown)
118118
}
119119
}
120120

121+
// An image embeds by vault-relative wikilink whatever folder the note is in,
122+
// matching the desktop and both mobile apps, so moving the note cannot break
123+
// the link (nothing rewrites relative asset paths on move).
124+
func TestImportAssetEmbedsImagesByVaultRelativeWikilink(t *testing.T) {
125+
root := t.TempDir()
126+
v, err := New(root, Options{})
127+
if err != nil {
128+
t.Fatal(err)
129+
}
130+
png := []byte{0x89, 'P', 'N', 'G'}
131+
for _, notePath := range []string{"Note.md", "inbox/Note.md", "inbox/deep/Note.md"} {
132+
asset, err := v.ImportAsset(notePath, "Photo.png", bytes.NewReader(png))
133+
if err != nil {
134+
t.Fatalf("%s: %v", notePath, err)
135+
}
136+
want := "![[" + asset.Path + "]]"
137+
if asset.Markdown != want {
138+
t.Fatalf("%s: markdown = %q, want %q", notePath, asset.Markdown, want)
139+
}
140+
if strings.Contains(asset.Markdown, "../") {
141+
t.Fatalf("%s: markdown = %q, must not be note-relative", notePath, asset.Markdown)
142+
}
143+
}
144+
}
145+
121146
func TestImportAssetReportsAtBoundary(t *testing.T) {
122147
root := t.TempDir()
123148
v, err := New(root, Options{MaxAssetBytes: 8})

0 commit comments

Comments
 (0)