dorfteich/apps/api/src/import-export/obsidian-vault.test.ts
Claude Fable 5 78a913c47b
Some checks failed
CD / Build and push images (push) Successful in 4m59s
CD / Deploy to Test (push) Successful in 11s
CI / Lint, typecheck, test (push) Failing after 5m36s
CI / Auth e2e pack (push) Has been skipped
CI / Import/export fidelity gate (push) Has been skipped
CI / Build container images (push) Has been skipped
CD / Smoke tests against Test (push) Successful in 1m21s
CD / Promote to Int (push) Successful in 11s
Vault import: decode ZIP names as UTF-8 and NFC-normalize them
Real vault ZIPs broke umlauts in page titles ("Fußball zum Götzen" →
mojibake, slug fua-ball…goi-tzen): fflate honors only the ZIP UTF-8
flag, which common archivers omit, and decodes unflagged names as
Latin-1. That decoding is byte-lossless, so parseVaultZip now re-reads
any name whose chars all fit one byte as UTF-8 (a strict decoder —
genuine Latin-1 and flag-decoded precomposed chars fall back
unchanged), then NFC-normalizes: macOS zips store umlauts decomposed,
which silently broke slugify's ä→ae digraphs, wikilink matching, and
duplicate-basename detection. slugify itself also precomposes first as
defense in depth for NFD input from other paths.

Unit tests pin both cases: a hand-patched ZIP whose UTF-8 name bytes
carry no UTF-8 flag, and an NFD-named note that must come out
precomposed with an ueber- slug.

Pages already imported with garbled titles stay as they are — delete
the imported subtree and re-import after this lands (or rename by
hand).

Fixes #127

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fb2VzvcoBPHkjh8bZ6PzQn
2026-07-15 13:24:58 +02:00

257 lines
10 KiB
TypeScript

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<string, Uint8Array> = {}): Uint8Array {
const entries: Record<string, Uint8Array> = { ...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));
}
}
});
});