import { readdirSync, readFileSync, statSync } from 'node:fs'; import { join, relative } from 'node:path'; import { zipSync } from 'fflate'; import { describe, expect, it } from 'vitest'; import { ASSET_PLACEHOLDER_PREFIX, VaultError, extractFrontmatter, extractInlineTags, parseVaultZip, planFolders, planSlugs, planVaultImport, } from './obsidian-vault'; const FIXTURE_DIR = join(__dirname, '../../../../fixtures/import/obsidian-vault'); /** Zips the checked-in fixture vault (also the e2e helper's job later, #119). */ function fixtureZip(extra: Record = {}): Uint8Array { const entries: Record = { ...extra }; const walk = (dir: string): void => { for (const name of readdirSync(dir)) { const path = join(dir, name); if (statSync(path).isDirectory()) walk(path); else entries[relative(FIXTURE_DIR, path)] = new Uint8Array(readFileSync(path)); } }; walk(FIXTURE_DIR); return zipSync(entries); } function planFixture(mountDepth = 0, frontmatterMode: 'strip' | 'preserve' = 'strip') { return planVaultImport(fixtureZip(), { frontmatterMode, existingSlugs: new Set(), mountDepth, }); } describe('parseVaultZip (issue #116)', () => { it('splits notes from assets and skips dot-directories', () => { const vault = parseVaultZip(fixtureZip()); expect(vault.notes.map((n) => n.path)).toEqual([ 'Notizen/Über Uns', 'Projekte/Projekt A', 'Projekte/Projekt B', 'Projekte/Tief/Ebene4/Ebene5/Tiefe Notiz', 'Startseite', 'Über Uns', ]); expect(vault.assets.map((a) => a.path)).toEqual(['media/teich.png', 'media/unterlagen.pdf']); expect(vault.assets[0]!.extension).toBe('png'); }); it('decodes UTF-8 entry names even without the ZIP UTF-8 flag (#127)', () => { // Real-world archivers often omit the UTF-8 flag; fflate then decodes // the name bytes as Latin-1 ("Fußball" → mojibake in page titles). // Build such a ZIP by zipping an ASCII placeholder of the same byte // length (keeps the flag clear) and patching in the raw UTF-8 bytes. const utf8Name = new TextEncoder().encode('Grüße.md'); const placeholder = 'GrAABBe.md'; expect(utf8Name.length).toBe(placeholder.length); const zipped = zipSync({ [placeholder]: new TextEncoder().encode('# Hallo') }); const placeholderBytes = new TextEncoder().encode(placeholder); for (let i = 0; i <= zipped.length - placeholderBytes.length; i += 1) { if (placeholderBytes.every((byte, j) => zipped[i + j] === byte)) { zipped.set(utf8Name, i); // local header + central directory } } const vault = parseVaultZip(zipped); expect(vault.notes.map((n) => n.name)).toEqual(['Grüße']); }); it('NFC-normalizes decomposed names so umlaut digraphs survive (#127)', () => { // macOS zips store umlauts decomposed (NFD): "\u00dc" as U + combining mark. const nfdName = 'U\u0308ber NFD.md'; expect(nfdName.normalize('NFC')).not.toBe(nfdName); // really decomposed const vault = parseVaultZip(fixtureZip({ [nfdName]: new TextEncoder().encode('# NFD') })); const note = vault.notes.find((n) => n.path.includes('NFD')); expect(note?.name).toBe('\u00dcber NFD'); // precomposed const plan = planVaultImport(fixtureZip({ [nfdName]: new TextEncoder().encode('x') }), { frontmatterMode: 'strip', existingSlugs: new Set(), mountDepth: 0, }); expect(plan.notes.map((n) => n.slug)).toContain('ueber-nfd'); }); it('rejects zip-slip paths, garbage, and oversized vaults', () => { expect(() => parseVaultZip(new Uint8Array([1, 2, 3]))).toThrowError(VaultError); expect(() => parseVaultZip(fixtureZip({ '../evil.md': new TextEncoder().encode('x') })), ).toThrowError(/Illegal path/); expect(() => parseVaultZip(fixtureZip(), 64)).toThrowError(/too large/); try { parseVaultZip(fixtureZip(), 64); } catch (error) { expect((error as VaultError).code).toBe('import_vault_too_large'); } }); }); describe('extractFrontmatter', () => { const note = '---\ntitle: X\ntags: [a, b/c]\n---\n\nBody.'; it('strips the block and reads inline-array tags', () => { const result = extractFrontmatter(note, 'strip'); expect(result.markdown).toBe('\nBody.'); expect(result.tags).toEqual([['a'], ['b', 'c']]); }); it('preserves the block as a yaml code fence', () => { const result = extractFrontmatter(note, 'preserve'); expect(result.markdown).toContain('```yaml\ntitle: X\ntags: [a, b/c]\n```'); expect(result.markdown.trimEnd().endsWith('Body.')).toBe(true); }); it('reads block-list and scalar tag forms; leaves notes without frontmatter alone', () => { const block = extractFrontmatter('---\ntags:\n - team\n - "x/y"\n---\nHi', 'strip'); expect(block.tags).toEqual([['team'], ['x', 'y']]); const scalar = extractFrontmatter('---\ntag: solo\n---\nHi', 'strip'); expect(scalar.tags).toEqual([['solo']]); const none = extractFrontmatter('# Just a heading\n---\nnot frontmatter', 'strip'); expect(none.markdown).toContain('# Just a heading'); expect(none.tags).toEqual([]); }); }); describe('extractInlineTags', () => { it('collects nested tags, removes them, and never touches code or headings', () => { const result = extractInlineTags( '# Heading stays\n\nText #eins mitten im Satz und #a/b am Ende.\n' + 'Code: `#nichttag` bleibt.\n\n```\n#auchkeintag\n```\n\nNummern #123 bleiben.', ); expect(result.tags).toEqual([['eins'], ['a', 'b']]); expect(result.markdown).toContain('# Heading stays'); expect(result.markdown).toContain('Text mitten im Satz und am Ende.'); expect(result.markdown).toContain('`#nichttag`'); expect(result.markdown).toContain('#auchkeintag'); expect(result.markdown).toContain('#123'); }); }); describe('planFolders / planSlugs', () => { it('merges the deepest folder levels beyond the budget', () => { const vault = parseVaultZip(fixtureZip()); const { containers, parentKeyByNote } = planFolders(vault, 3); // Assets never create containers (no `media` here). expect(containers.map((c) => c.title)).toEqual([ 'Notizen', 'Projekte', 'Tief', 'Ebene4/Ebene5', ]); expect(parentKeyByNote.get('Projekte/Tief/Ebene4/Ebene5/Tiefe Notiz')).toBe( 'Projekte/Tief/Ebene4/Ebene5', ); expect(parentKeyByNote.get('Startseite')).toBeNull(); }); it('suffixes collisions against existing and batch slugs deterministically', () => { const slugs = planSlugs( [ { key: 'a', base: 'Über Uns' }, { key: 'b', base: 'über uns' }, { key: 'c', base: 'Taken' }, ], new Set(['taken']), ); expect(slugs.get('a')).toBe('ueber-uns'); expect(slugs.get('b')).toBe('ueber-uns-2'); expect(slugs.get('c')).toBe('taken-2'); }); }); describe('planVaultImport (the whole transform)', () => { it('rewrites every link form to final slugs', () => { const plan = planFixture(); const note = (path: string) => plan.notes.find((n) => n.key === `note:${path}`)!; const start = note('Startseite'); // Basename link → resolved slug with the original name as display. expect(start.markdown).toContain('[[projekt-a|Projekt A]]'); // Full-path link keeps its display text. expect(start.markdown).toContain('[[projekt-b|Das zweite Projekt]]'); // Duplicate basename: lexicographically first path (Notizen/Über Uns) wins. expect(start.markdown).toContain('[[ueber-uns|Über Uns]]'); expect(note('Notizen/Über Uns').slug).toBe('ueber-uns'); expect(note('Über Uns').slug).toBe('ueber-uns-2'); // Unresolvable → phantom out of the slugified name. expect(start.markdown).toContain('[[fehlt-noch|Fehlt Noch]]'); // Heading link strips the fragment. expect(note('Projekte/Projekt B').markdown).toContain('[[projekt-a|Projekt A]]'); }); it('turns image embeds into placeholders and other files into attachments', () => { const plan = planFixture(); const a = plan.notes.find((n) => n.key === 'note:Projekte/Projekt A')!; expect(a.markdown).toContain(`![teich.png](${ASSET_PLACEHOLDER_PREFIX}media/teich.png)`); expect(a.imageAssets).toEqual(['media/teich.png']); const b = plan.notes.find((n) => n.key === 'note:Projekte/Projekt B')!; expect(b.markdown).toContain('*unterlagen.pdf*'); expect(b.attachmentAssets).toEqual(['media/unterlagen.pdf']); // Code spans and fences survive untouched. expect(b.markdown).toContain('`[[NichtLink]] #nichttag`'); expect(b.markdown).toContain('[[AuchKeinLink]] #auchkeintag'); // A relative standard-markdown image resolves too. const about = plan.notes.find((n) => n.key === 'note:Über Uns')!; expect(about.markdown).toContain(`![Logo](${ASSET_PLACEHOLDER_PREFIX}media/teich.png)`); expect([...plan.referencedAssets].sort()).toEqual(['media/teich.png', 'media/unterlagen.pdf']); }); it('collects frontmatter and inline tags per note', () => { const plan = planFixture(); const a = plan.notes.find((n) => n.key === 'note:Projekte/Projekt A')!; expect(a.tags).toEqual([['projekt'], ['status', 'aktiv']]); expect(a.markdown).not.toContain('#status/aktiv'); expect(a.markdown).not.toContain('tags: [projekt'); const start = plan.notes.find((n) => n.key === 'note:Startseite')!; expect(start.tags).toEqual([['willkommen']]); }); it('honors the preserve frontmatter mode', () => { const plan = planFixture(0, 'preserve'); const a = plan.notes.find((n) => n.key === 'note:Projekte/Projekt A')!; expect(a.markdown).toContain('```yaml'); expect(a.markdown).toContain('title: Projekt A'); }); it('budgets container levels from the mount depth', () => { // Mount at depth 0 (pond root): 5 container levels available — the deep // path (4 folders) fits unmerged. const roomy = planFixture(0); expect(roomy.containers.map((c) => c.title)).toContain('Ebene5'); // Mount at depth 2: 3 container levels — the deepest two merge. const tight = planFixture(2); expect(tight.containers.map((c) => c.title)).toContain('Ebene4/Ebene5'); const deep = tight.notes.find((n) => n.key.includes('Tiefe Notiz'))!; expect(deep.parentKey).toBe('container:Projekte/Tief/Ebene4/Ebene5'); // Containers stay parents-before-children. const keys = tight.containers.map((c) => c.key); for (const container of tight.containers) { if (container.parentKey) { expect(keys.indexOf(container.parentKey)).toBeLessThan(keys.indexOf(container.key)); } } }); });