Vault import: decode ZIP names as UTF-8 and NFC-normalize them
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

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
This commit is contained in:
Claude Fable 5 2026-07-15 13:24:58 +02:00
parent 32d4cd0e4b
commit 78a913c47b
3 changed files with 80 additions and 13 deletions

View File

@ -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(() =>

View File

@ -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('/');

View File

@ -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(/-+$/, '')
);
}