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
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:
parent
32d4cd0e4b
commit
78a913c47b
@ -54,6 +54,41 @@ describe('parseVaultZip (issue #116)', () => {
|
|||||||
expect(vault.assets[0]!.extension).toBe('png');
|
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', () => {
|
it('rejects zip-slip paths, garbage, and oversized vaults', () => {
|
||||||
expect(() => parseVaultZip(new Uint8Array([1, 2, 3]))).toThrowError(VaultError);
|
expect(() => parseVaultZip(new Uint8Array([1, 2, 3]))).toThrowError(VaultError);
|
||||||
expect(() =>
|
expect(() =>
|
||||||
|
|||||||
@ -99,6 +99,32 @@ function safeRelativePath(rawPath: string): string {
|
|||||||
return path;
|
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),
|
* Unpacks the vault ZIP into notes (`.md`) and assets (everything else),
|
||||||
* enforcing the unpacked-size ceiling incrementally. Hidden housekeeping
|
* enforcing the unpacked-size ceiling incrementally. Hidden housekeeping
|
||||||
@ -119,7 +145,8 @@ export function parseVaultZip(
|
|||||||
const notes: VaultNote[] = [];
|
const notes: VaultNote[] = [];
|
||||||
const assets: VaultAsset[] = [];
|
const assets: VaultAsset[] = [];
|
||||||
let unpacked = 0;
|
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
|
if (rawPath.endsWith('/')) continue; // directory entries
|
||||||
const path = safeRelativePath(rawPath);
|
const path = safeRelativePath(rawPath);
|
||||||
const segments = path.split('/');
|
const segments = path.split('/');
|
||||||
|
|||||||
@ -112,16 +112,21 @@ const MAX_SLUG_LENGTH = 60;
|
|||||||
* Uniqueness (numeric suffixes) is the caller's job, not slugify's.
|
* Uniqueness (numeric suffixes) is the caller's job, not slugify's.
|
||||||
*/
|
*/
|
||||||
export function slugify(input: string): string {
|
export function slugify(input: string): string {
|
||||||
return input
|
return (
|
||||||
.toLowerCase()
|
input
|
||||||
.replace(/ä/g, 'ae')
|
// Precompose first (#127): macOS file names arrive NFD-decomposed, and
|
||||||
.replace(/ö/g, 'oe')
|
// the umlaut digraphs below only match the precomposed forms.
|
||||||
.replace(/ü/g, 'ue')
|
.normalize('NFC')
|
||||||
.replace(/ß/g, 'ss')
|
.toLowerCase()
|
||||||
.normalize('NFKD')
|
.replace(/ä/g, 'ae')
|
||||||
.replace(/[\u0300-\u036f]/g, '')
|
.replace(/ö/g, 'oe')
|
||||||
.replace(/[^a-z0-9]+/g, '-')
|
.replace(/ü/g, 'ue')
|
||||||
.replace(/^-+|-+$/g, '')
|
.replace(/ß/g, 'ss')
|
||||||
.slice(0, MAX_SLUG_LENGTH)
|
.normalize('NFKD')
|
||||||
.replace(/-+$/, '');
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
.slice(0, MAX_SLUG_LENGTH)
|
||||||
|
.replace(/-+$/, '')
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user