Obsidian vault transform module (#116)
Some checks failed
CI / Lint, typecheck, test (push) Failing after 52s
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 / Build and push images (push) Failing after 1m36s
CD / Deploy to Test (push) Has been skipped
CD / Smoke tests against Test (push) Has been skipped
CD / Promote to Int (push) Has been skipped

Pure functions from vault ZIP to import plan — no DB, no DI:

- parseVaultZip: fflate unzip with the plugin-package protections
  (zip-slip rejection, incremental unpacked ceiling 256 MiB,
  parameterized for tests); dot-directories like .obsidian/ skipped;
  deterministic ordering.
- extractFrontmatter: leading --- block, tags:/tag: in scalar, inline-
  array, and block-list forms; strip mode drops the block, preserve
  re-emits it as a fenced yaml code block.
- extractInlineTags: fence- and inline-code-aware #tag / #nested/tag
  extraction and removal (headings and pure numbers untouched).
- rewriteLinks: [[Name]], [[Name|Display]], [[Name#Heading]] (fragment
  stripped), [[folder/Name]] (path match beats basename) → the FINAL
  slug with the human name as display; unresolvable → slugified
  phantom; ![[img]] and relative ![](path) → vault-asset: placeholders
  the uploader resolves (#117); non-image embeds → italic filename +
  page attachment; SVG deliberately stays an attachment (never inline,
  security.md); note embeds degrade to plain wikilinks.
- planFolders: folder chains merged at the deepest levels to fit
  MAX_PAGE_DEPTH below the mount page (merged titles read c/d).
- planSlugs: -n suffixing against existing ∪ batch; duplicate
  basenames resolve to the lexicographically first vault path.
- planVaultImport ties it together into containers + notes + the
  referenced-asset set.

Fixture vault under fixtures/import/obsidian-vault/ (umlauts,
duplicate basenames, nested tags, deep folders, embeds, code traps);
13 unit tests colocated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Claude Fable 5 2026-07-14 16:51:31 +02:00
parent 64e21e9f94
commit 09abda3ada
11 changed files with 818 additions and 0 deletions

View File

@ -0,0 +1,221 @@
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('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));
}
}
});
});

View File

@ -0,0 +1,547 @@
import { MAX_PAGE_DEPTH, slugify } from '@dorfteich/shared';
import { unzipSync } from 'fflate';
/**
* Obsidian vault transform (issue #116): pure functions from "uploaded ZIP"
* to an import plan no DB, no DI, fully unit-testable. The orchestration
* (issue #117) feeds the plan into the page/file/label services.
*
* The core impedance mismatch: Obsidian links reference note NAMES
* (`[[Meine Notiz]]`, `[[folder/Note#Heading|shown]]`), Dorfteich wikilinks
* resolve by SLUG within a pond and the shared markdown parser reads
* `[[target]]` verbatim. So every rewrite here is a text pre-pass over the
* note Markdown, done before `markdownToDoc` ever sees it.
*/
export class VaultError extends Error {
constructor(
readonly code: 'import_vault_invalid_zip' | 'import_vault_too_large',
message: string,
) {
super(message);
}
}
/** Zip-bomb ceiling for the unpacked vault (the ZIP itself is capped by the
* upload limit). Matches the plugin-package model. */
export const MAX_VAULT_UNPACKED_BYTES = 256 * 1024 * 1024;
/** Raster formats that become inline images. SVG deliberately stays an
* attachment it is never served inline (security.md §Uploads). */
const IMAGE_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp']);
/** Placeholder scheme the orchestrator replaces with real file ids after
* uploading: `![alt](vault-asset:<vault path>)`. */
export const ASSET_PLACEHOLDER_PREFIX = 'vault-asset:';
export interface VaultNote {
/** Vault-relative path without the `.md` extension. */
path: string;
/** Basename without `.md` — what Obsidian links refer to. */
name: string;
markdown: string;
}
export interface VaultAsset {
path: string;
name: string;
data: Uint8Array;
/** Lowercase extension without the dot. */
extension: string;
}
export interface Vault {
notes: VaultNote[];
assets: VaultAsset[];
}
export type FrontmatterMode = 'strip' | 'preserve';
export interface PlannedContainer {
/** Unique key: the merged folder path. */
key: string;
parentKey: string | null;
/** Human title — a merged folder reads like `c/d`. */
title: string;
slug: string;
}
export interface PlannedNote {
key: string;
parentKey: string | null;
title: string;
slug: string;
/** Rewritten Markdown: links final slugs, tags stripped, frontmatter
* handled, asset embeds `vault-asset:` placeholders. */
markdown: string;
/** Tag paths (nested tags split at `/`), e.g. [['projekt'], ['a','b']]. */
tags: string[][];
/** Image assets referenced by this note (vault paths). */
imageAssets: string[];
/** Non-image assets referenced by this note (become page attachments). */
attachmentAssets: string[];
}
export interface VaultImportPlan {
/** Topologically ordered (parents before children). */
containers: PlannedContainer[];
notes: PlannedNote[];
/** Vault paths of every asset actually referenced by some note. */
referencedAssets: Set<string>;
}
/** Rejects absolute paths and `..` traversal (zip-slip), plugin-package model. */
function safeRelativePath(rawPath: string): string {
const path = rawPath.replace(/\\/g, '/').replace(/^\/+/, '');
if (path.split('/').some((segment) => segment === '..')) {
throw new VaultError('import_vault_invalid_zip', `Illegal path in archive: ${rawPath}`);
}
return path;
}
/**
* Unpacks the vault ZIP into notes (`.md`) and assets (everything else),
* enforcing the unpacked-size ceiling incrementally. Hidden housekeeping
* directories (`.obsidian/`, `.trash/`, any dot-directory) are skipped
* they hold app config, not content.
*/
export function parseVaultZip(
zip: Uint8Array,
maxUnpackedBytes: number = MAX_VAULT_UNPACKED_BYTES,
): Vault {
let entries: Record<string, Uint8Array>;
try {
entries = unzipSync(zip);
} catch {
throw new VaultError('import_vault_invalid_zip', 'The file is not a valid ZIP archive');
}
const notes: VaultNote[] = [];
const assets: VaultAsset[] = [];
let unpacked = 0;
for (const [rawPath, bytes] of Object.entries(entries)) {
if (rawPath.endsWith('/')) continue; // directory entries
const path = safeRelativePath(rawPath);
const segments = path.split('/');
if (segments.some((segment) => segment.startsWith('.'))) continue;
unpacked += bytes.length;
if (unpacked > maxUnpackedBytes) {
throw new VaultError('import_vault_too_large', 'The unpacked vault is too large');
}
const basename = segments[segments.length - 1]!;
if (basename.toLowerCase().endsWith('.md')) {
const noteName = basename.slice(0, -3);
notes.push({
path: path.slice(0, -3),
name: noteName,
markdown: new TextDecoder().decode(bytes),
});
} else {
const dot = basename.lastIndexOf('.');
assets.push({
path,
name: basename,
data: bytes,
extension: dot >= 0 ? basename.slice(dot + 1).toLowerCase() : '',
});
}
}
if (notes.length === 0) {
throw new VaultError('import_vault_invalid_zip', 'The archive contains no Markdown notes');
}
// Deterministic plan regardless of zip entry order.
notes.sort((a, b) => a.path.localeCompare(b.path));
assets.sort((a, b) => a.path.localeCompare(b.path));
return { notes, assets };
}
export interface Frontmatter {
/** The note body with the frontmatter handled per mode. */
markdown: string;
/** Tag paths extracted from `tags:`/`tag:` (nested split at `/`). */
tags: string[][];
}
function parseTagValue(value: string): string[][] {
// `tags: a, b` · `tags: [a, b]` · quoted variants.
const inner = value.trim().replace(/^\[/, '').replace(/\]$/, '');
return inner
.split(',')
.map((tag) => tag.trim().replace(/^["'#]+|["']+$/g, ''))
.filter(Boolean)
.map((tag) => tag.split('/').filter(Boolean));
}
/**
* Handles a leading YAML frontmatter block. Only the `tags:` forms Obsidian
* uses are interpreted (scalar list, inline array, block list) everything
* else is opaque. `strip` drops the block; `preserve` re-emits it as a
* fenced yaml code block so nothing is lost but nothing leaks as prose.
*/
export function extractFrontmatter(markdown: string, mode: FrontmatterMode): Frontmatter {
const match = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/.exec(markdown);
if (!match) return { markdown, tags: [] };
const block = match[1]!;
const body = markdown.slice(match[0].length);
const tags: string[][] = [];
const lines = block.split(/\r?\n/);
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i]!;
const keyMatch = /^(tags?)[ \t]*:[ \t]*(.*)$/i.exec(line);
if (!keyMatch) continue;
const value = keyMatch[2]!.trim();
if (value) {
tags.push(...parseTagValue(value));
continue;
}
// Block list form: consume the following `- item` lines.
for (let j = i + 1; j < lines.length; j += 1) {
const item = /^[ \t]+-[ \t]*(.+)$/.exec(lines[j]!);
if (!item) break;
tags.push(...parseTagValue(item[1]!));
}
}
const preserved = mode === 'preserve' ? `\`\`\`yaml\n${block}\n\`\`\`\n\n` : '';
return { markdown: preserved + body, tags };
}
/**
* Splits Markdown into alternating segments outside/inside code so the tag
* and link passes never touch fenced blocks or inline code. Yields the
* outside segments to `transform` and stitches everything back together.
*/
function transformOutsideCode(markdown: string, transform: (segment: string) => string): string {
const lines = markdown.split('\n');
const out: string[] = [];
let inFence = false;
let fenceMarker = '';
for (const line of lines) {
const fence = /^([ \t]*)(```+|~~~+)/.exec(line);
if (fence && !inFence) {
inFence = true;
fenceMarker = fence[2]![0]!;
out.push(line);
continue;
}
if (fence && inFence && fence[2]![0] === fenceMarker) {
inFence = false;
out.push(line);
continue;
}
if (inFence) {
out.push(line);
continue;
}
// Outside fences: protect inline code spans.
const parts = line.split(/(`[^`]*`)/);
out.push(parts.map((part) => (part.startsWith('`') ? part : transform(part))).join(''));
}
return out.join('\n');
}
export interface InlineTags {
markdown: string;
tags: string[][];
}
/**
* Extracts inline `#tag` / `#nested/tag` occurrences and removes them from
* the text (Stefan's decision: ALL Obsidian tags become labels). Headings are
* safe `#` followed by whitespace never matches; pure-number "tags" (like
* anchors or issue refs) are ignored, matching Obsidian's own rule.
*/
export function extractInlineTags(markdown: string): InlineTags {
const tags: string[][] = [];
const result = transformOutsideCode(markdown, (segment) =>
segment.replace(/(^|[\s(])#([\p{L}\p{N}_/-]+)/gu, (whole, prefix: string, tag: string) => {
if (!/\p{L}/u.test(tag)) return whole; // needs at least one letter
tags.push(tag.split('/').filter(Boolean));
return prefix;
}),
);
// Collapse the whitespace runs left behind by removed tags.
const cleaned = transformOutsideCode(result, (segment) =>
segment.replace(/[ \t]{2,}/g, ' ').replace(/[ \t]+$/g, ''),
);
return { markdown: cleaned, tags };
}
/** How a `[[target]]` resolves: to a note, an asset, or nothing. */
export interface LinkResolver {
/** Final slug for a note target, or null when unresolvable. */
noteSlug(target: string): string | null;
/** Vault path of an asset target, or null. */
assetPath(target: string): { path: string; extension: string } | null;
}
/**
* Builds the vault-wide resolver: case-insensitive basename matching with
* full-path precedence, duplicates resolved to the lexicographically first
* vault path (documented rule; Obsidian's "closest" has no meaning without
* a source-file anchor when the whole vault imports at once).
*/
export function buildResolver(vault: Vault, noteSlugByPath: Map<string, string>): LinkResolver {
const notesByName = new Map<string, string>(); // lower name → path (first wins)
const notesByPath = new Map<string, string>(); // lower path → path
for (const note of vault.notes) {
const lowerName = note.name.toLowerCase();
if (!notesByName.has(lowerName)) notesByName.set(lowerName, note.path);
notesByPath.set(note.path.toLowerCase(), note.path);
}
const assetsByName = new Map<string, VaultAsset>();
const assetsByPath = new Map<string, VaultAsset>();
for (const asset of vault.assets) {
const lowerName = asset.name.toLowerCase();
if (!assetsByName.has(lowerName)) assetsByName.set(lowerName, asset);
assetsByPath.set(asset.path.toLowerCase(), asset);
}
return {
noteSlug(target: string): string | null {
const lower = target.toLowerCase().replace(/\.md$/, '');
const path = lower.includes('/')
? (notesByPath.get(lower) ?? notesByName.get(lower.split('/').pop()!))
: notesByName.get(lower);
return path ? (noteSlugByPath.get(path) ?? null) : null;
},
assetPath(target: string): { path: string; extension: string } | null {
const lower = target.toLowerCase();
const asset = lower.includes('/')
? (assetsByPath.get(lower) ?? assetsByName.get(lower.split('/').pop()!))
: assetsByName.get(lower);
return asset ? { path: asset.path, extension: asset.extension } : null;
},
};
}
export interface RewrittenLinks {
markdown: string;
imageAssets: string[];
attachmentAssets: string[];
}
/**
* The link pass (fence-aware): rewrites every Obsidian link form to what the
* shared markdown parser understands.
* - `[[Name]]`, `[[Name|Display]]`, `[[Name#Heading]]`, `[[folder/Name]]`
* `[[final-slug|display or original name]]` (heading stripped).
* - Unresolvable note targets `[[slugify(target)|target]]` a phantom
* link, exactly like typing a dead wikilink by hand.
* - `![[image.png]]` / `![](relative/path.png)` on a vault image
* `![name](vault-asset:path)` for the uploader to resolve.
* - `![[doc.pdf]]` (non-image asset) `*doc.pdf*` text; the file becomes a
* page attachment (paperclip panel), which has no inline node.
* - `![[Other Note]]` (note embed) a plain wikilink (no transclusion).
*/
export function rewriteLinks(markdown: string, resolver: LinkResolver): RewrittenLinks {
const imageAssets = new Set<string>();
const attachmentAssets = new Set<string>();
const rewriteTarget = (rawTarget: string, rawDisplay: string | undefined): string => {
const target = rawTarget.split('#')[0]!.trim();
if (!target) return rawDisplay ?? rawTarget; // pure heading link — keep text
const display = rawDisplay?.trim() || target.split('/').pop()!;
const slug = resolver.noteSlug(target) ?? (slugify(target.split('/').pop()!) || 'page');
return `[[${slug}|${display}]]`;
};
const result = transformOutsideCode(markdown, (segment) => {
let out = segment;
// Embeds first — `![[…]]` would otherwise be eaten as `!` + wikilink.
out = out.replace(/!\[\[([^[\]\n]+)\]\]/g, (_whole, inner: string) => {
const [target, display] = splitPipe(inner);
const asset = resolver.assetPath(target.split('#')[0]!.trim());
if (asset) {
if (IMAGE_EXTENSIONS.has(asset.extension)) {
imageAssets.add(asset.path);
const alt = display ?? asset.path.split('/').pop()!;
return `![${alt}](${ASSET_PLACEHOLDER_PREFIX}${asset.path})`;
}
attachmentAssets.add(asset.path);
return `*${display ?? asset.path.split('/').pop()!}*`;
}
// A note embed (transclusion) degrades to a normal link.
return rewriteTarget(target, display);
});
// Standard markdown images with a relative vault path.
out = out.replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, (whole, alt: string, src: string) => {
if (/^[a-z][a-z0-9+.-]*:/i.test(src)) return whole; // absolute URL/data:
const asset = resolver.assetPath(decodeURIComponent(src));
if (!asset) return whole;
if (IMAGE_EXTENSIONS.has(asset.extension)) {
imageAssets.add(asset.path);
return `![${alt}](${ASSET_PLACEHOLDER_PREFIX}${asset.path})`;
}
attachmentAssets.add(asset.path);
return `*${alt || asset.name}*`;
});
// Plain wikilinks.
out = out.replace(/\[\[([^[\]\n]+)\]\]/g, (_whole, inner: string) => {
const [target, display] = splitPipe(inner);
return rewriteTarget(target, display);
});
return out;
});
return {
markdown: result,
imageAssets: [...imageAssets],
attachmentAssets: [...attachmentAssets],
};
}
function splitPipe(inner: string): [string, string | undefined] {
const pipe = inner.indexOf('|');
if (pipe < 0) return [inner, undefined];
return [inner.slice(0, pipe), inner.slice(pipe + 1)];
}
/**
* Maps every note's folder path to a container chain of at most
* `maxContainerLevels` (the deepest levels merge into one container whose
* title keeps the joined path, e.g. `c/d`). Returns the ordered container
* list (parents first) and the container key each note hangs under.
*/
export function planFolders(
vault: Vault,
maxContainerLevels: number,
): {
containers: { key: string; parentKey: string | null; title: string }[];
parentKeyByNote: Map<string, string | null>;
} {
const containers = new Map<string, { key: string; parentKey: string | null; title: string }>();
const parentKeyByNote = new Map<string, string | null>();
for (const note of vault.notes) {
const folders = note.path.split('/').slice(0, -1);
if (folders.length === 0 || maxContainerLevels <= 0) {
parentKeyByNote.set(note.path, null);
continue;
}
const kept = folders.slice(0, Math.max(0, maxContainerLevels - 1));
const merged = folders.slice(Math.max(0, maxContainerLevels - 1));
const chain = merged.length > 0 ? [...kept, merged.join('/')] : kept;
let parentKey: string | null = null;
let keyPrefix = '';
for (const title of chain) {
keyPrefix = keyPrefix ? `${keyPrefix}/${title}` : title;
if (!containers.has(keyPrefix)) {
containers.set(keyPrefix, { key: keyPrefix, parentKey, title });
}
parentKey = keyPrefix;
}
parentKeyByNote.set(note.path, parentKey);
}
// Map insertion already guarantees parents-before-children (each chain is
// walked top-down), and iteration order is insertion order.
return { containers: [...containers.values()], parentKeyByNote };
}
/**
* Assigns final slugs to every container and note: `-2/-3` suffixing against
* the existing pond slugs and everything planned so far. Deterministic:
* containers (path order) before notes (path order).
*/
export function planSlugs(
entries: { key: string; base: string }[],
existingSlugs: ReadonlySet<string>,
): Map<string, string> {
const taken = new Set(existingSlugs);
const result = new Map<string, string>();
for (const entry of entries) {
const base = slugify(entry.base) || 'page';
let slug = base;
for (let n = 2; taken.has(slug); n += 1) slug = `${base}-${n}`;
taken.add(slug);
result.set(entry.key, slug);
}
return result;
}
/**
* The whole transform: ZIP bytes + options an import plan the orchestrator
* (issue #117) can feed into the page/file/label services.
*
* `mountDepth` is the 1-based depth of the chosen parent page (0 for the
* pond root): containers + note level must fit inside MAX_PAGE_DEPTH.
*/
export function planVaultImport(
zip: Uint8Array,
options: {
frontmatterMode: FrontmatterMode;
existingSlugs: ReadonlySet<string>;
mountDepth: number;
},
): VaultImportPlan {
const vault = parseVaultZip(zip);
// Levels available below the mount page, minus one for the notes.
const maxContainerLevels = Math.max(0, MAX_PAGE_DEPTH - options.mountDepth - 1);
const { containers, parentKeyByNote } = planFolders(vault, maxContainerLevels);
// Reserve every slug up front — containers first, then notes; the note
// key is its vault path.
const slugEntries = [
...containers.map((c) => ({ key: `container:${c.key}`, base: c.title.split('/').pop()! })),
...vault.notes.map((note) => ({ key: `note:${note.path}`, base: note.name })),
];
const slugs = planSlugs(slugEntries, options.existingSlugs);
const noteSlugByPath = new Map<string, string>();
for (const note of vault.notes) {
noteSlugByPath.set(note.path, slugs.get(`note:${note.path}`)!);
}
const resolver = buildResolver(vault, noteSlugByPath);
const plannedNotes: PlannedNote[] = vault.notes.map((note) => {
const frontmatter = extractFrontmatter(note.markdown, options.frontmatterMode);
const inline = extractInlineTags(frontmatter.markdown);
const links = rewriteLinks(inline.markdown, resolver);
return {
key: `note:${note.path}`,
parentKey: parentKeyByNote.get(note.path)
? `container:${parentKeyByNote.get(note.path)!}`
: null,
title: note.name,
slug: noteSlugByPath.get(note.path)!,
markdown: links.markdown,
tags: dedupeTags([...frontmatter.tags, ...inline.tags]),
imageAssets: links.imageAssets,
attachmentAssets: links.attachmentAssets,
};
});
const referencedAssets = new Set<string>();
for (const note of plannedNotes) {
for (const path of note.imageAssets) referencedAssets.add(path);
for (const path of note.attachmentAssets) referencedAssets.add(path);
}
return {
containers: containers.map((c) => ({
key: `container:${c.key}`,
parentKey: c.parentKey ? `container:${c.parentKey}` : null,
title: c.title,
slug: slugs.get(`container:${c.key}`)!,
})),
notes: plannedNotes,
referencedAssets,
};
}
function dedupeTags(tags: string[][]): string[][] {
const seen = new Set<string>();
const result: string[][] = [];
for (const tag of tags) {
if (tag.length === 0) continue;
const key = tag.join('/').toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
result.push(tag);
}
return result;
}

View File

@ -0,0 +1 @@
{"theme":"moonstone"}

View File

@ -0,0 +1,3 @@
# Über Uns (Notizen)
Der Namensvetter im Notizen-Ordner.

View File

@ -0,0 +1,14 @@
---
title: Projekt A
tags: [projekt, status/aktiv]
---
# Projekt A
Zustaendig: [[Über Uns|das Team]] — Status #status/aktiv
![[teich.png]]
## Ziele
Mehr Seerosen.

View File

@ -0,0 +1,9 @@
# Projekt B
Siehe [[Projekt A#Ziele]] und die Unterlagen: ![[unterlagen.pdf]]
Im Code bleibt alles stehen: `[[NichtLink]] #nichttag`
```text
[[AuchKeinLink]] #auchkeintag
```

View File

@ -0,0 +1,3 @@
# Tiefe Notiz
Ganz unten. Zurueck zur [[Startseite]].

View File

@ -0,0 +1,8 @@
# Startseite
Willkommen im Vault. #willkommen
- Projekt: [[Projekt A]]
- Direkt per Pfad: [[Projekte/Projekt B|Das zweite Projekt]]
- Team: [[Über Uns]]
- Noch offen: [[Fehlt Noch]]

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

View File

@ -0,0 +1,4 @@
%PDF-1.4
1 0 obj<<>>endobj
trailer<<>>
%%EOF

View File

@ -0,0 +1,8 @@
---
tags:
- team
---
# Über Uns
Wir sind der Teich. ![Logo](media/teich.png)