@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.
- package/dist/api-error.d.ts +14 -0
- package/dist/api-error.js +19 -0
- package/dist/api-error.test.d.ts +1 -0
- package/dist/api-error.test.js +26 -0
- package/dist/client.d.ts +61 -0
- package/dist/client.js +96 -0
- package/dist/export.d.ts +11 -3
- package/dist/export.js +96 -159
- package/dist/export.test.d.ts +1 -0
- package/dist/export.test.js +107 -0
- package/dist/grammar-media.d.ts +52 -0
- package/dist/grammar-media.js +77 -0
- package/dist/grammar-media.test.d.ts +1 -0
- package/dist/grammar-media.test.js +108 -0
- package/dist/import-local.d.ts +41 -0
- package/dist/import-local.js +206 -0
- package/dist/import-local.test.d.ts +1 -0
- package/dist/import-local.test.js +19 -0
- package/dist/index.js +8 -9
- package/dist/sources/local.d.ts +24 -0
- package/dist/sources/local.js +22 -0
- package/dist/sources/local.test.js +1 -1
- package/dist/sources/notion.d.ts +7 -1
- package/dist/sources/notion.js +14 -8
- package/dist/sources/notion.test.d.ts +1 -0
- package/dist/sources/notion.test.js +45 -0
- package/package.json +9 -1
|
@@ -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
|
-
|
|
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
|
-
*
|
|
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();
|