@moxn/kb-migrate 0.4.34 → 0.4.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,107 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest';
2
+ import * as fs from 'fs';
3
+ import * as os from 'os';
4
+ import * as path from 'path';
5
+ import { runExport } from './export.js';
6
+ import { MoxnClient } from './client.js';
7
+ /**
8
+ * Issue 1 (Phase 9a) — the export must emit CLEAN grammar (no H2 `{#id}`
9
+ * section anchors), so a re-home import (`replace_document` to a NEW path /
10
+ * doc) doesn't reject the foreign ids. Previously the export passed
11
+ * `forEdit: true`, which keeps the source doc's section anchors and breaks
12
+ * the CREATE/re-home path. These tests pin the export at the clean contract.
13
+ */
14
+ describe('runExport — clean grammar (no {#id} anchors, Issue 1)', () => {
15
+ afterEach(() => {
16
+ vi.restoreAllMocks();
17
+ });
18
+ function makeOptions() {
19
+ return {
20
+ apiUrl: 'http://localhost:3001',
21
+ apiKey: 'test-key',
22
+ basePath: '',
23
+ imageDir: 'images',
24
+ pdfDir: 'pdfs',
25
+ csvDir: 'csvs',
26
+ dryRun: false,
27
+ };
28
+ }
29
+ it('calls getDocumentMarkdown WITHOUT forEdit (clean export)', async () => {
30
+ const outputDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kb-export-clean-'));
31
+ // One document, no media — isolate the forEdit assertion.
32
+ vi.spyOn(MoxnClient.prototype, 'listDocuments').mockResolvedValue([
33
+ {
34
+ id: 'doc-1',
35
+ path: '/guides/intro',
36
+ name: 'intro',
37
+ description: null,
38
+ createdAt: '2026-01-01T00:00:00.000Z',
39
+ updatedAt: null,
40
+ },
41
+ ]);
42
+ const getMd = vi
43
+ .spyOn(MoxnClient.prototype, 'getDocumentMarkdown')
44
+ .mockResolvedValue({
45
+ id: 'doc-1',
46
+ branchName: 'main',
47
+ markdown: [
48
+ '---',
49
+ 'name: intro',
50
+ 'path: /guides/intro',
51
+ '---',
52
+ '',
53
+ '## Setup',
54
+ '',
55
+ 'body',
56
+ ].join('\n'),
57
+ });
58
+ await runExport(outputDir, makeOptions());
59
+ expect(getMd).toHaveBeenCalledTimes(1);
60
+ const [, opts] = getMd.mock.calls[0];
61
+ // The load-bearing assertion: NO forEdit (undefined, or explicitly falsy).
62
+ // A clean export must not request the source doc's section anchors.
63
+ expect(opts?.forEdit ?? false).toBe(false);
64
+ fs.rmSync(outputDir, { recursive: true, force: true });
65
+ });
66
+ it('writes a body with no H2 {#id} anchors (re-home import would not reject)', async () => {
67
+ const outputDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kb-export-noanchor-'));
68
+ vi.spyOn(MoxnClient.prototype, 'listDocuments').mockResolvedValue([
69
+ {
70
+ id: 'doc-2',
71
+ path: '/guides/deep/topic',
72
+ name: 'topic',
73
+ description: null,
74
+ createdAt: '2026-01-01T00:00:00.000Z',
75
+ updatedAt: null,
76
+ },
77
+ ]);
78
+ // The CLEAN grammar a forEdit:false read emits — H2s carry NO `{#id}`.
79
+ const cleanMarkdown = [
80
+ '---',
81
+ 'name: topic',
82
+ 'path: /guides/deep/topic',
83
+ '---',
84
+ '',
85
+ '## Overview',
86
+ '',
87
+ 'overview prose',
88
+ '',
89
+ '## Details',
90
+ '',
91
+ 'details prose',
92
+ ].join('\n');
93
+ vi.spyOn(MoxnClient.prototype, 'getDocumentMarkdown').mockResolvedValue({
94
+ id: 'doc-2',
95
+ branchName: 'main',
96
+ markdown: cleanMarkdown,
97
+ });
98
+ await runExport(outputDir, makeOptions());
99
+ const written = fs.readFileSync(path.join(outputDir, 'guides/deep/topic.md'), 'utf-8');
100
+ // No `{#<id>}` heading anchor of any form survives in the exported body.
101
+ expect(written).not.toMatch(/\{#[^}]+\}/);
102
+ // Sanity: the headings + prose are still present (we didn't drop content).
103
+ expect(written).toContain('## Overview');
104
+ expect(written).toContain('## Details');
105
+ fs.rmSync(outputDir, { recursive: true, force: true });
106
+ });
107
+ });
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Grammar media embed helpers (Phase 9a-3).
3
+ *
4
+ * The moxn grammar markdown the `/api/v1/kb/export` `get_document_markdown`
5
+ * action emits carries media as line-leading directives:
6
+ *
7
+ * :::image{ref="<storage-key>" alt="…" w="…" align="…" mime="…"}
8
+ * :::file{ref="<storage-key>" filename="…" mime="…" size=123 …}
9
+ * :::csv{ref="<storage-key>" filename="…" headers=[a,b] rowCount=10 …}
10
+ * :::db{ref="<database-id>"}
11
+ *
12
+ * For a SELF-CONTAINED local export, the file-adapter downloads each media file
13
+ * and rewrites the embed `ref` from the storage key to a RELATIVE path; on
14
+ * import it uploads the local file and rewrites the `ref` back to the storage
15
+ * key BEFORE handing the grammar to `import_markdown` (which ingests refs as-is,
16
+ * exactly like the MCP `read`).
17
+ *
18
+ * This module is a deliberately FOCUSED regex over the embed lines — kb-migrate
19
+ * is a separate package and cannot import the in-`src` grammar codec
20
+ * (`src/lib/kb/grammar/embeds.ts`). It reads/rewrites ONLY the `ref` attribute
21
+ * and preserves every other attribute and its order verbatim.
22
+ *
23
+ * IMPORTANT: `:::db` refs are DATABASE IDs (the embed travels by reference, not
24
+ * as a file) — they are NEVER treated as media. Only `image | file | csv` are.
25
+ */
26
+ /** Embed directive names that carry a media file via `ref` (NOT `db`). */
27
+ export declare const MEDIA_DIRECTIVES: readonly ["image", "file", "csv"];
28
+ export type MediaDirective = (typeof MEDIA_DIRECTIVES)[number];
29
+ /** A media embed discovered in a grammar markdown string. */
30
+ export interface MediaEmbedRef {
31
+ /** Directive name: 'image' | 'file' | 'csv'. */
32
+ directive: MediaDirective;
33
+ /** The current value of the `ref` attribute (a storage key or a relative path). */
34
+ ref: string;
35
+ }
36
+ /**
37
+ * Extract every media embed `ref` (image/file/csv) from a grammar markdown
38
+ * string, in document order. `:::db` embeds are intentionally excluded (their
39
+ * ref is a database id, not a file). Duplicate refs are returned once each
40
+ * occurrence — callers that want uniqueness should dedupe on `ref`.
41
+ */
42
+ export declare function extractMediaRefs(markdown: string): MediaEmbedRef[];
43
+ /**
44
+ * Rewrite the `ref` attribute of every media embed (image/file/csv) using
45
+ * `mapRef`, preserving the directive name, every other attribute, and their
46
+ * order. `:::db` embeds are left untouched.
47
+ *
48
+ * `mapRef(currentRef, directive)` returns the replacement ref, or `null` /
49
+ * `undefined` to leave that embed's ref unchanged (e.g. an external URL we
50
+ * chose not to localize, or a missing upload).
51
+ */
52
+ export declare function rewriteMediaRefs(markdown: string, mapRef: (ref: string, directive: MediaDirective) => string | null | undefined): string;
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Grammar media embed helpers (Phase 9a-3).
3
+ *
4
+ * The moxn grammar markdown the `/api/v1/kb/export` `get_document_markdown`
5
+ * action emits carries media as line-leading directives:
6
+ *
7
+ * :::image{ref="<storage-key>" alt="…" w="…" align="…" mime="…"}
8
+ * :::file{ref="<storage-key>" filename="…" mime="…" size=123 …}
9
+ * :::csv{ref="<storage-key>" filename="…" headers=[a,b] rowCount=10 …}
10
+ * :::db{ref="<database-id>"}
11
+ *
12
+ * For a SELF-CONTAINED local export, the file-adapter downloads each media file
13
+ * and rewrites the embed `ref` from the storage key to a RELATIVE path; on
14
+ * import it uploads the local file and rewrites the `ref` back to the storage
15
+ * key BEFORE handing the grammar to `import_markdown` (which ingests refs as-is,
16
+ * exactly like the MCP `read`).
17
+ *
18
+ * This module is a deliberately FOCUSED regex over the embed lines — kb-migrate
19
+ * is a separate package and cannot import the in-`src` grammar codec
20
+ * (`src/lib/kb/grammar/embeds.ts`). It reads/rewrites ONLY the `ref` attribute
21
+ * and preserves every other attribute and its order verbatim.
22
+ *
23
+ * IMPORTANT: `:::db` refs are DATABASE IDs (the embed travels by reference, not
24
+ * as a file) — they are NEVER treated as media. Only `image | file | csv` are.
25
+ */
26
+ /** Embed directive names that carry a media file via `ref` (NOT `db`). */
27
+ export const MEDIA_DIRECTIVES = ['image', 'file', 'csv'];
28
+ /**
29
+ * Matches a single media embed line and captures:
30
+ * [1] directive name (image|file|csv)
31
+ * [2] the attrs before `ref="…"` (may be empty)
32
+ * [3] the ref value (inside the quotes)
33
+ * [4] the attrs after the ref (may be empty)
34
+ *
35
+ * Anchored to the start of a line (multiline) so it only ever matches a
36
+ * line-leading directive, never `:::image{…}` appearing inside prose/code.
37
+ * `ref` is the first attribute the serializer emits for every media node, so a
38
+ * leading-`ref` match covers the export's output; we still allow attrs before
39
+ * `ref` for robustness against hand-authored files.
40
+ *
41
+ * The `before` group `((?:[^}]*?\s)?)` is either empty (ref is the first attr)
42
+ * or ends in whitespace, so `ref=` only matches a whole-attribute key — never
43
+ * the tail of another key like `href=`/`xref=`.
44
+ */
45
+ const MEDIA_EMBED_RE = /^(:::(image|file|csv)\{)((?:[^}]*?\s)?)ref="([^"]*)"([^}]*)(\})/gm;
46
+ /**
47
+ * Extract every media embed `ref` (image/file/csv) from a grammar markdown
48
+ * string, in document order. `:::db` embeds are intentionally excluded (their
49
+ * ref is a database id, not a file). Duplicate refs are returned once each
50
+ * occurrence — callers that want uniqueness should dedupe on `ref`.
51
+ */
52
+ export function extractMediaRefs(markdown) {
53
+ const refs = [];
54
+ // Use a fresh regex (global state is per-RegExp); reset lastIndex defensively.
55
+ const re = new RegExp(MEDIA_EMBED_RE.source, 'gm');
56
+ let m;
57
+ while ((m = re.exec(markdown)) !== null) {
58
+ refs.push({ directive: m[2], ref: m[4] });
59
+ }
60
+ return refs;
61
+ }
62
+ /**
63
+ * Rewrite the `ref` attribute of every media embed (image/file/csv) using
64
+ * `mapRef`, preserving the directive name, every other attribute, and their
65
+ * order. `:::db` embeds are left untouched.
66
+ *
67
+ * `mapRef(currentRef, directive)` returns the replacement ref, or `null` /
68
+ * `undefined` to leave that embed's ref unchanged (e.g. an external URL we
69
+ * chose not to localize, or a missing upload).
70
+ */
71
+ export function rewriteMediaRefs(markdown, mapRef) {
72
+ return markdown.replace(MEDIA_EMBED_RE, (_full, open, directive, before, ref, after, close) => {
73
+ const next = mapRef(ref, directive);
74
+ const value = next == null ? ref : next;
75
+ return `${open}${before}ref="${value}"${after}${close}`;
76
+ });
77
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,108 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { extractMediaRefs, rewriteMediaRefs, } from './grammar-media.js';
3
+ describe('extractMediaRefs', () => {
4
+ it('returns [] for a doc with no embeds', () => {
5
+ const md = `---\nname: Plain\npath: /plain\n---\n\n## Body\n\nJust prose.\n`;
6
+ expect(extractMediaRefs(md)).toEqual([]);
7
+ });
8
+ it('extracts a single image ref', () => {
9
+ const md = `## Pics\n\n:::image{ref="org_t1/a.png" alt="A" w="100%" align="center" mime="image/png"}\n`;
10
+ expect(extractMediaRefs(md)).toEqual([{ directive: 'image', ref: 'org_t1/a.png' }]);
11
+ });
12
+ it('extracts image, file, and csv refs in document order', () => {
13
+ const md = [
14
+ ':::image{ref="org_t1/a.png" alt="A"}',
15
+ ':::file{ref="org_t1/b.pdf" filename="b.pdf" mime="application/pdf"}',
16
+ ':::csv{ref="org_t1/c.csv" filename="c.csv" headers=[x,y] rowCount=3}',
17
+ ].join('\n\n');
18
+ expect(extractMediaRefs(md)).toEqual([
19
+ { directive: 'image', ref: 'org_t1/a.png' },
20
+ { directive: 'file', ref: 'org_t1/b.pdf' },
21
+ { directive: 'csv', ref: 'org_t1/c.csv' },
22
+ ]);
23
+ });
24
+ it('does NOT extract :::db embeds (db ref is a database id, not media)', () => {
25
+ const md = `:::db{ref="11111111-2222-3333-4444-555555555555"}\n\n:::image{ref="org_t1/a.png"}`;
26
+ expect(extractMediaRefs(md)).toEqual([{ directive: 'image', ref: 'org_t1/a.png' }]);
27
+ });
28
+ it('ignores a :::image directive that is not line-leading (inside prose)', () => {
29
+ const md = `Inline mention of :::image{ref="x"} should not match.\n`;
30
+ expect(extractMediaRefs(md)).toEqual([]);
31
+ });
32
+ it('returns each occurrence of a duplicate ref', () => {
33
+ const md = `:::image{ref="org_t1/a.png"}\n\n:::image{ref="org_t1/a.png" alt="again"}`;
34
+ expect(extractMediaRefs(md)).toEqual([
35
+ { directive: 'image', ref: 'org_t1/a.png' },
36
+ { directive: 'image', ref: 'org_t1/a.png' },
37
+ ]);
38
+ });
39
+ it('matches the real `ref` attr even when a prior attr key ends in "ref"', () => {
40
+ // A hypothetical leading attr whose key ends in "ref" must NOT be captured
41
+ // as the ref (boundary hardening: ref= only matches a whole attr key).
42
+ const md = `:::image{xref="decoy" ref="org_t1/a.png" alt="A"}`;
43
+ expect(extractMediaRefs(md)).toEqual([{ directive: 'image', ref: 'org_t1/a.png' }]);
44
+ });
45
+ });
46
+ describe('rewriteMediaRefs', () => {
47
+ it('is a no-op for a doc with no embeds', () => {
48
+ const md = `## Body\n\nNo media here.\n`;
49
+ expect(rewriteMediaRefs(md, () => 'WRONG')).toBe(md);
50
+ });
51
+ it('rewrites an image ref storage-key → relative path, preserving other attrs + order', () => {
52
+ const md = `:::image{ref="org_t1/a.png" alt="A" w="100%" align="center" mime="image/png"}`;
53
+ const out = rewriteMediaRefs(md, (ref) => ref === 'org_t1/a.png' ? 'images/a.png' : null);
54
+ expect(out).toBe(`:::image{ref="images/a.png" alt="A" w="100%" align="center" mime="image/png"}`);
55
+ });
56
+ it('rewrites a relative path back to a storage key (the import direction)', () => {
57
+ const md = `:::csv{ref="csvs/c.csv" filename="c.csv" headers=[x,y] rowCount=3}`;
58
+ const out = rewriteMediaRefs(md, (ref) => ref === 'csvs/c.csv' ? 'org_t1/uploaded.csv' : null);
59
+ expect(out).toBe(`:::csv{ref="org_t1/uploaded.csv" filename="c.csv" headers=[x,y] rowCount=3}`);
60
+ });
61
+ it('rewrites multiple embeds independently and routes by directive', () => {
62
+ const md = [
63
+ ':::image{ref="org_t1/a.png" alt="A"}',
64
+ ':::file{ref="org_t1/b.pdf" filename="b.pdf"}',
65
+ ':::csv{ref="org_t1/c.csv" filename="c.csv"}',
66
+ ].join('\n\n');
67
+ const seen = [];
68
+ const out = rewriteMediaRefs(md, (ref, directive) => {
69
+ seen.push([ref, directive]);
70
+ return `${directive}s/${ref.split('/').pop()}`;
71
+ });
72
+ expect(out).toBe([
73
+ ':::image{ref="images/a.png" alt="A"}',
74
+ ':::file{ref="files/b.pdf" filename="b.pdf"}',
75
+ ':::csv{ref="csvs/c.csv" filename="c.csv"}',
76
+ ].join('\n\n'));
77
+ expect(seen).toEqual([
78
+ ['org_t1/a.png', 'image'],
79
+ ['org_t1/b.pdf', 'file'],
80
+ ['org_t1/c.csv', 'csv'],
81
+ ]);
82
+ });
83
+ it('leaves the ref unchanged when mapRef returns null or undefined', () => {
84
+ const md = `:::image{ref="https://external/x.png" alt="ext"}`;
85
+ expect(rewriteMediaRefs(md, () => null)).toBe(md);
86
+ expect(rewriteMediaRefs(md, () => undefined)).toBe(md);
87
+ });
88
+ it('never rewrites a :::db ref', () => {
89
+ const md = `:::db{ref="db-uuid-123"}\n\n:::image{ref="org_t1/a.png"}`;
90
+ const out = rewriteMediaRefs(md, () => 'images/a.png');
91
+ expect(out).toBe(`:::db{ref="db-uuid-123"}\n\n:::image{ref="images/a.png"}`);
92
+ });
93
+ it('round-trips: export rewrite (key→path) then import rewrite (path→key) restores the original', () => {
94
+ const original = `:::image{ref="org_t1/a.png" alt="A" mime="image/png"}\n\n:::file{ref="org_t1/b.pdf" filename="b.pdf"}`;
95
+ const keyToPath = new Map([
96
+ ['org_t1/a.png', 'images/a.png'],
97
+ ['org_t1/b.pdf', 'files/b.pdf'],
98
+ ]);
99
+ const exported = rewriteMediaRefs(original, (ref) => keyToPath.get(ref) ?? null);
100
+ expect(extractMediaRefs(exported).map((r) => r.ref)).toEqual([
101
+ 'images/a.png',
102
+ 'files/b.pdf',
103
+ ]);
104
+ const pathToKey = new Map([...keyToPath.entries()].map(([k, v]) => [v, k]));
105
+ const reimported = rewriteMediaRefs(exported, (ref) => pathToKey.get(ref) ?? null);
106
+ expect(reimported).toBe(original);
107
+ });
108
+ });
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Local GRAMMAR-markdown import runner (Phase 9a-3).
3
+ *
4
+ * The inverse of {@link runExport}: reads each local `.md` file VERBATIM as
5
+ * moxn-grammar markdown (front-matter + body + `:::image/:::csv/:::file/:::db`
6
+ * embeds), uploads any local media + rewrites the embed `ref`s from relative
7
+ * paths to STORAGE keys, then UPSERTs the document via the `import_markdown`
8
+ * action (over the shipped `replace_document` verb).
9
+ *
10
+ * This replaces the legacy blocks path (`LocalSource.extract` → MCP blocks →
11
+ * `create_document`) for the LOCAL source only. Notion/OneNote stay on blocks
12
+ * (they migrate in 9b).
13
+ */
14
+ import { MoxnClient } from './client.js';
15
+ import { LocalSource } from './sources/local.js';
16
+ import type { MigrationLog, MigrationOptions } from './types.js';
17
+ /**
18
+ * Join a base path and a relative KB path into a single absolute KB path
19
+ * (mirrors MoxnClient.buildPath so the grammar import places docs identically
20
+ * to the blocks import). E.g. ("/imported", "subdir/doc") → "/imported/subdir/doc".
21
+ */
22
+ export declare function joinKbPath(basePath: string, relativePath: string): string;
23
+ /**
24
+ * Upload the local media files referenced by a grammar markdown string and
25
+ * rewrite each embed `ref` from its (relative or absolute) local path to the
26
+ * returned storage key. Refs that are external URLs, or whose file can't be
27
+ * read, are left untouched (logged). Returns the rewritten markdown + a count
28
+ * of uploaded files.
29
+ *
30
+ * `baseDir` is the directory of the source `.md` file — relative refs resolve
31
+ * against it (matching how the export wrote them, relative to the `.md`).
32
+ */
33
+ export declare function uploadAndRewriteMedia(markdown: string, baseDir: string, client: MoxnClient): Promise<{
34
+ markdown: string;
35
+ uploaded: number;
36
+ }>;
37
+ /**
38
+ * Run a local grammar-markdown migration: discover files via LocalSource, then
39
+ * for each file upload its media + `import_markdown` it.
40
+ */
41
+ export declare function runLocalGrammarMigration(source: LocalSource, options: MigrationOptions): Promise<MigrationLog>;
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Local GRAMMAR-markdown import runner (Phase 9a-3).
3
+ *
4
+ * The inverse of {@link runExport}: reads each local `.md` file VERBATIM as
5
+ * moxn-grammar markdown (front-matter + body + `:::image/:::csv/:::file/:::db`
6
+ * embeds), uploads any local media + rewrites the embed `ref`s from relative
7
+ * paths to STORAGE keys, then UPSERTs the document via the `import_markdown`
8
+ * action (over the shipped `replace_document` verb).
9
+ *
10
+ * This replaces the legacy blocks path (`LocalSource.extract` → MCP blocks →
11
+ * `create_document`) for the LOCAL source only. Notion/OneNote stay on blocks
12
+ * (they migrate in 9b).
13
+ */
14
+ import * as fs from 'fs/promises';
15
+ import * as path from 'path';
16
+ import { MoxnClient } from './client.js';
17
+ import { extractMediaRefs, rewriteMediaRefs, } from './grammar-media.js';
18
+ /**
19
+ * Join a base path and a relative KB path into a single absolute KB path
20
+ * (mirrors MoxnClient.buildPath so the grammar import places docs identically
21
+ * to the blocks import). E.g. ("/imported", "subdir/doc") → "/imported/subdir/doc".
22
+ */
23
+ export function joinKbPath(basePath, relativePath) {
24
+ const base = basePath.replace(/^\/+|\/+$/g, '');
25
+ const rel = relativePath.replace(/^\/+|\/+$/g, '');
26
+ return '/' + (base ? `${base}/${rel}` : rel);
27
+ }
28
+ /** Guess an upload MIME type from a media file's extension. */
29
+ function mediaTypeFromPath(filePath) {
30
+ const ext = path.extname(filePath).toLowerCase();
31
+ const map = {
32
+ '.png': 'image/png',
33
+ '.jpg': 'image/jpeg',
34
+ '.jpeg': 'image/jpeg',
35
+ '.gif': 'image/gif',
36
+ '.webp': 'image/webp',
37
+ '.svg': 'image/svg+xml',
38
+ '.pdf': 'application/pdf',
39
+ '.csv': 'text/csv',
40
+ };
41
+ return map[ext] || 'application/octet-stream';
42
+ }
43
+ /** True for an embed ref that is an external URL (left as-is, never uploaded). */
44
+ function isExternalUrl(ref) {
45
+ return (ref.startsWith('http://') || ref.startsWith('https://') || ref.startsWith('data:'));
46
+ }
47
+ /**
48
+ * Upload the local media files referenced by a grammar markdown string and
49
+ * rewrite each embed `ref` from its (relative or absolute) local path to the
50
+ * returned storage key. Refs that are external URLs, or whose file can't be
51
+ * read, are left untouched (logged). Returns the rewritten markdown + a count
52
+ * of uploaded files.
53
+ *
54
+ * `baseDir` is the directory of the source `.md` file — relative refs resolve
55
+ * against it (matching how the export wrote them, relative to the `.md`).
56
+ */
57
+ export async function uploadAndRewriteMedia(markdown, baseDir, client) {
58
+ const refs = extractMediaRefs(markdown);
59
+ if (refs.length === 0)
60
+ return { markdown, uploaded: 0 };
61
+ // Resolve each UNIQUE local ref → storage key (skip externals/missing).
62
+ const refToKey = new Map();
63
+ let uploaded = 0;
64
+ for (const { ref } of refs) {
65
+ if (refToKey.has(ref) || isExternalUrl(ref))
66
+ continue;
67
+ const localPath = path.isAbsolute(ref) ? ref : path.join(baseDir, ref);
68
+ let data;
69
+ try {
70
+ data = await fs.readFile(localPath);
71
+ }
72
+ catch {
73
+ console.error(` ✗ Media not found, leaving ref as-is: ${ref}`);
74
+ continue;
75
+ }
76
+ const mediaType = mediaTypeFromPath(localPath);
77
+ const filename = path.basename(localPath);
78
+ try {
79
+ const { key } = await client.uploadFile(data, mediaType, filename);
80
+ refToKey.set(ref, key);
81
+ uploaded++;
82
+ }
83
+ catch (err) {
84
+ const msg = err instanceof Error ? err.message : String(err);
85
+ console.error(` ✗ Media upload failed, leaving ref as-is: ${ref}: ${msg}`);
86
+ }
87
+ }
88
+ const rewritten = rewriteMediaRefs(markdown, (ref, _directive) => refToKey.get(ref) ?? null);
89
+ return { markdown: rewritten, uploaded };
90
+ }
91
+ /**
92
+ * Run a local grammar-markdown migration: discover files via LocalSource, then
93
+ * for each file upload its media + `import_markdown` it.
94
+ */
95
+ export async function runLocalGrammarMigration(source, options) {
96
+ const startTime = Date.now();
97
+ const results = [];
98
+ await source.validate();
99
+ const totalCount = await source.getDocumentCount();
100
+ const client = new MoxnClient(options);
101
+ let processed = 0;
102
+ let consecutiveFailures = 0;
103
+ const MAX_CONSECUTIVE_FAILURES = 10;
104
+ for await (const file of source.extractGrammarFiles()) {
105
+ processed++;
106
+ const progress = totalCount ? ` (${processed}/${totalCount})` : '';
107
+ console.log(`Processing: ${file.sourcePath}${progress}`);
108
+ const result = await migrateGrammarFile(client, file, options);
109
+ results.push(result);
110
+ if (result.status === 'failed') {
111
+ consecutiveFailures++;
112
+ }
113
+ else {
114
+ consecutiveFailures = 0;
115
+ }
116
+ const statusIcon = {
117
+ created: '✓',
118
+ updated: '↻',
119
+ skipped: '-',
120
+ failed: '✗',
121
+ }[result.status];
122
+ console.log(` ${statusIcon} ${result.status}: ${result.documentPath}`);
123
+ if (result.error) {
124
+ console.log(` Error: ${result.error}`);
125
+ }
126
+ if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
127
+ console.error(`\nAborting: ${MAX_CONSECUTIVE_FAILURES} consecutive failures. ` +
128
+ 'Last error: ' +
129
+ (result.error || 'unknown') +
130
+ '\nFix the underlying issue and retry. Remaining documents will be skipped.');
131
+ break;
132
+ }
133
+ }
134
+ const summary = {
135
+ total: results.length,
136
+ created: results.filter((r) => r.status === 'created').length,
137
+ updated: results.filter((r) => r.status === 'updated').length,
138
+ skipped: results.filter((r) => r.status === 'skipped').length,
139
+ failed: results.filter((r) => r.status === 'failed').length,
140
+ duration: Date.now() - startTime,
141
+ };
142
+ return {
143
+ timestamp: new Date().toISOString(),
144
+ source: { type: source.sourceType, location: source.sourceLocation },
145
+ targetApi: options.apiUrl,
146
+ basePath: options.basePath,
147
+ options: { dryRun: options.dryRun, onConflict: options.onConflict },
148
+ results,
149
+ summary,
150
+ };
151
+ }
152
+ /** Import a single grammar file: read → upload media → rewrite refs → import_markdown. */
153
+ async function migrateGrammarFile(client, file, options) {
154
+ const startTime = Date.now();
155
+ const documentPath = joinKbPath(options.basePath, file.kbPath);
156
+ if (options.dryRun) {
157
+ return {
158
+ sourcePath: file.sourcePath,
159
+ documentPath,
160
+ status: 'skipped',
161
+ duration: Date.now() - startTime,
162
+ };
163
+ }
164
+ try {
165
+ const raw = await fs.readFile(file.fullPath, 'utf-8');
166
+ // Upload local media + rewrite embed refs (relative path → storage key)
167
+ // BEFORE import_markdown — the API boundary expects storage refs.
168
+ const { markdown } = await uploadAndRewriteMedia(raw, path.dirname(file.fullPath), client);
169
+ const res = await client.importMarkdown({
170
+ markdown,
171
+ // Destination hint: the PARENT sets the folder, the tail is only a name
172
+ // fallback. The server derives the final path from the name (front-matter
173
+ // name → first H1 → filename), so res.path may differ from this.
174
+ path: documentPath,
175
+ onConflict: options.onConflict,
176
+ });
177
+ // Surface a non-fatal warning when the file carried a front-matter block
178
+ // that wasn't applied (no `name` → body-only import): tags/description/etc.
179
+ // declared in it were ignored.
180
+ if (res.frontMatterIgnored) {
181
+ console.warn(` ⚠ front-matter present but not applied (no \`name\`) — imported body only: ${file.sourcePath}`);
182
+ }
183
+ // Map import_markdown outcome → migration status.
184
+ const status = res.outcome === 'created'
185
+ ? 'created'
186
+ : res.outcome === 'updated'
187
+ ? 'updated'
188
+ : 'skipped';
189
+ return {
190
+ sourcePath: file.sourcePath,
191
+ documentPath: res.path || documentPath,
192
+ status,
193
+ documentId: res.id,
194
+ duration: Date.now() - startTime,
195
+ };
196
+ }
197
+ catch (error) {
198
+ return {
199
+ sourcePath: file.sourcePath,
200
+ documentPath,
201
+ status: 'failed',
202
+ error: error instanceof Error ? error.message : 'Unknown error',
203
+ duration: Date.now() - startTime,
204
+ };
205
+ }
206
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,19 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { joinKbPath } from './import-local.js';
3
+ describe('joinKbPath', () => {
4
+ it('prefixes the relative KB path under a base path', () => {
5
+ expect(joinKbPath('/imported', 'subdir/doc')).toBe('/imported/subdir/doc');
6
+ });
7
+ it('treats base "/" as the root (no prefix segment)', () => {
8
+ expect(joinKbPath('/', 'doc')).toBe('/doc');
9
+ });
10
+ it('treats an empty base as the root', () => {
11
+ expect(joinKbPath('', 'guides/intro')).toBe('/guides/intro');
12
+ });
13
+ it('strips leading/trailing slashes from both parts', () => {
14
+ expect(joinKbPath('/base/', '/sub/doc/')).toBe('/base/sub/doc');
15
+ });
16
+ it('collapses redundant slashes at the join seam', () => {
17
+ expect(joinKbPath('base', 'doc')).toBe('/base/doc');
18
+ });
19
+ });
package/dist/index.js CHANGED
@@ -20,6 +20,7 @@ import { getPageTitle } from './sources/notion-blocks.js';
20
20
  import { slugify } from './sources/notion.js';
21
21
  import { MoxnClient } from './client.js';
22
22
  import { runExport } from './export.js';
23
+ import { runLocalGrammarMigration } from './import-local.js';
23
24
  import { runNotionExport } from './export-notion.js';
24
25
  import { buildDateFilter } from './date-filter.js';
25
26
  const DEFAULT_API_URL = 'https://moxn.dev';
@@ -323,7 +324,11 @@ program
323
324
  dateFilter,
324
325
  };
325
326
  try {
326
- const log = await runMigration(source, migrationOptions);
327
+ // Phase 9a-3: the local source now imports GRAMMAR markdown via
328
+ // `import_markdown` (read each .md verbatim, upload + rewrite media refs,
329
+ // UPSERT through replace_document) instead of parsing to MCP blocks +
330
+ // create_document. Notion/OneNote stay on the blocks runMigration path.
331
+ const log = await runLocalGrammarMigration(source, migrationOptions);
327
332
  if (opts.json) {
328
333
  console.log(JSON.stringify(log, null, 2));
329
334
  }
@@ -950,15 +955,9 @@ async function importNotionDatabase(client, dbImport, log, options, preCreatedDb
950
955
  }
951
956
  /**
952
957
  * Slugify a string for use as a tag path segment.
953
- * Similar to page slug but for the tag hierarchy.
958
+ * Delegates to the shared notion slugify so tags and page paths normalize identically.
954
959
  */
955
960
  function slugifyTagPath(s) {
956
- return (s
957
- .toLowerCase()
958
- .trim()
959
- .replace(/[^\w\s-]/g, '')
960
- .replace(/[\s_]+/g, '-')
961
- .replace(/-+/g, '-')
962
- .replace(/^-|-$/g, '') || 'untitled');
961
+ return slugify(s);
963
962
  }
964
963
  program.parse();