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
257 lines
10 KiB
TypeScript
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(``);
|
|
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(``);
|
|
|
|
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));
|
|
}
|
|
}
|
|
});
|
|
});
|