diff --git a/apps/api/src/import-export/obsidian-vault.test.ts b/apps/api/src/import-export/obsidian-vault.test.ts index 26baa92..2847d3d 100644 --- a/apps/api/src/import-export/obsidian-vault.test.ts +++ b/apps/api/src/import-export/obsidian-vault.test.ts @@ -54,6 +54,41 @@ describe('parseVaultZip (issue #116)', () => { 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(() => diff --git a/apps/api/src/import-export/obsidian-vault.ts b/apps/api/src/import-export/obsidian-vault.ts index e1e305e..6fb0a00 100644 --- a/apps/api/src/import-export/obsidian-vault.ts +++ b/apps/api/src/import-export/obsidian-vault.ts @@ -99,6 +99,32 @@ function safeRelativePath(rawPath: string): string { return path; } +/** + * Repairs a ZIP entry name that fflate decoded as Latin-1 (#127). fflate + * honors only the ZIP UTF-8 flag (bit 11), which common archivers omit — + * the UTF-8 bytes of "Fußball" then arrive as one mojibake char per byte + * and end up in page titles. Latin-1 decoding is byte-lossless (charCode == + * byte), so when every char fits a byte we re-read them as UTF-8; names the + * flag DID mark as UTF-8 contain either multi-byte chars (> 0xFF, left + * alone) or precomposed Latin-1 chars whose bytes are invalid UTF-8 (the + * decoder throws → keep the original). Finally NFC-normalize: macOS zips + * store umlauts decomposed (NFD), which breaks slugify's ä→ae digraphs, + * wikilink matching, and duplicate-basename detection. + */ +function decodeZipName(raw: string): string { + let name = raw; + // eslint-disable-next-line no-control-regex + if (/^[\u0000-\u00ff]*$/.test(raw)) { + const bytes = Uint8Array.from(raw, (char) => char.charCodeAt(0)); + try { + name = new TextDecoder('utf-8', { fatal: true }).decode(bytes); + } catch { + // Genuinely Latin-1 (or already plain ASCII) — keep as decoded. + } + } + return name.normalize('NFC'); +} + /** * Unpacks the vault ZIP into notes (`.md`) and assets (everything else), * enforcing the unpacked-size ceiling incrementally. Hidden housekeeping @@ -119,7 +145,8 @@ export function parseVaultZip( const notes: VaultNote[] = []; const assets: VaultAsset[] = []; let unpacked = 0; - for (const [rawPath, bytes] of Object.entries(entries)) { + for (const [zipName, bytes] of Object.entries(entries)) { + const rawPath = decodeZipName(zipName); if (rawPath.endsWith('/')) continue; // directory entries const path = safeRelativePath(rawPath); const segments = path.split('/'); diff --git a/packages/shared/src/ponds.ts b/packages/shared/src/ponds.ts index 0946615..5328213 100644 --- a/packages/shared/src/ponds.ts +++ b/packages/shared/src/ponds.ts @@ -112,16 +112,21 @@ const MAX_SLUG_LENGTH = 60; * Uniqueness (numeric suffixes) is the caller's job, not slugify's. */ export function slugify(input: string): string { - return input - .toLowerCase() - .replace(/ä/g, 'ae') - .replace(/ö/g, 'oe') - .replace(/ü/g, 'ue') - .replace(/ß/g, 'ss') - .normalize('NFKD') - .replace(/[\u0300-\u036f]/g, '') - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, '') - .slice(0, MAX_SLUG_LENGTH) - .replace(/-+$/, ''); + return ( + input + // Precompose first (#127): macOS file names arrive NFD-decomposed, and + // the umlaut digraphs below only match the precomposed forms. + .normalize('NFC') + .toLowerCase() + .replace(/ä/g, 'ae') + .replace(/ö/g, 'oe') + .replace(/ü/g, 'ue') + .replace(/ß/g, 'ss') + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, MAX_SLUG_LENGTH) + .replace(/-+$/, '') + ); }