@moxn/kb-migrate 0.5.0 → 0.6.0
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 +7 -0
- package/dist/api-error.js +21 -0
- package/dist/client-http.test.d.ts +1 -0
- package/dist/client-http.test.js +89 -0
- package/dist/client.d.ts +61 -0
- package/dist/client.js +111 -38
- package/dist/export.js +94 -4
- package/dist/export.test.js +121 -0
- package/dist/http.d.ts +1 -0
- package/dist/http.js +16 -0
- package/dist/import-local.js +1 -0
- package/dist/import-local.test.js +96 -0
- package/dist/index.js +39 -12
- package/dist/output.d.ts +17 -0
- package/dist/output.js +65 -0
- package/dist/output.test.d.ts +1 -0
- package/dist/output.test.js +59 -0
- package/dist/targets/notion.js +3 -2
- package/dist/types.d.ts +6 -1
- package/package.json +2 -1
package/dist/export.js
CHANGED
|
@@ -45,6 +45,75 @@ function mediaTargetForDirective(directive, options) {
|
|
|
45
45
|
function docPathToFilePath(docPath) {
|
|
46
46
|
return `${docPath.replace(/^\//, '')}.md`;
|
|
47
47
|
}
|
|
48
|
+
/** A KB path → its directory-relative location with a given extension. */
|
|
49
|
+
function docPathWithExt(docPath, ext) {
|
|
50
|
+
return `${docPath.replace(/^\//, '')}${ext}`;
|
|
51
|
+
}
|
|
52
|
+
/** A document that could not be exported for a reason that is not an error. */
|
|
53
|
+
class SkipExport extends Error {
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Export a document the grammar export has no body for (report / file /
|
|
57
|
+
* slides), via the `read` tool. Returns the directory-relative files written
|
|
58
|
+
* (the first is the document's own output file). Throws on a read error
|
|
59
|
+
* (→ failed) and {@link SkipExport} on a kind it does not know (→ skipped),
|
|
60
|
+
* so nothing is ever reported "exported" without its body on disk.
|
|
61
|
+
*/
|
|
62
|
+
async function exportNonMarkdownDocument(client, docPath, documentId, outputDir, dryRun, claimed) {
|
|
63
|
+
const item = await client.readDocument(documentId);
|
|
64
|
+
if (item.error)
|
|
65
|
+
throw new Error(item.error);
|
|
66
|
+
const write = (relPath, text) => {
|
|
67
|
+
if (dryRun)
|
|
68
|
+
return;
|
|
69
|
+
const full = path.join(outputDir, relPath);
|
|
70
|
+
fs.mkdirSync(path.dirname(full), { recursive: true });
|
|
71
|
+
fs.writeFileSync(full, text, 'utf-8');
|
|
72
|
+
};
|
|
73
|
+
const htmlBody = () => {
|
|
74
|
+
const html = item.content?.find((c) => typeof c.text === 'string')?.text;
|
|
75
|
+
if (html === undefined)
|
|
76
|
+
throw new Error(`read returned no ${item.kind} HTML`);
|
|
77
|
+
return html;
|
|
78
|
+
};
|
|
79
|
+
switch (item.kind) {
|
|
80
|
+
case 'report': {
|
|
81
|
+
const out = docPathWithExt(docPath, '.html');
|
|
82
|
+
write(out, htmlBody());
|
|
83
|
+
return [out];
|
|
84
|
+
}
|
|
85
|
+
case 'slides': {
|
|
86
|
+
const out = docPathWithExt(docPath, '.html');
|
|
87
|
+
write(out, htmlBody());
|
|
88
|
+
const themeCss = item.deck?.themeCss;
|
|
89
|
+
if (!themeCss)
|
|
90
|
+
return [out];
|
|
91
|
+
const theme = docPathWithExt(docPath, '.theme.css');
|
|
92
|
+
write(theme, themeCss);
|
|
93
|
+
return [out, theme];
|
|
94
|
+
}
|
|
95
|
+
case 'file': {
|
|
96
|
+
if (!item.file)
|
|
97
|
+
throw new Error('read returned no file metadata');
|
|
98
|
+
// The blob under its ORIGINAL filename, beside where the doc lives. The
|
|
99
|
+
// filename is server data: only its basename is used, and a name another
|
|
100
|
+
// document in this run already took falls back to the (unique) KB path.
|
|
101
|
+
const folder = path.posix.dirname(docPath.replace(/^\//, ''));
|
|
102
|
+
const base = path.basename(item.file.filename);
|
|
103
|
+
let out = base && base !== '..' ? path.posix.join(folder, base) : '';
|
|
104
|
+
if (!out || claimed.has(out))
|
|
105
|
+
out = docPath.replace(/^\//, '');
|
|
106
|
+
if (!dryRun) {
|
|
107
|
+
const full = path.join(outputDir, out);
|
|
108
|
+
fs.mkdirSync(path.dirname(full), { recursive: true });
|
|
109
|
+
await client.downloadFile(item.file.download.url, full);
|
|
110
|
+
}
|
|
111
|
+
return [out];
|
|
112
|
+
}
|
|
113
|
+
default:
|
|
114
|
+
throw new SkipExport(`kind '${item.kind ?? 'unknown'}' has no local export format; nothing written`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
48
117
|
// ──────────────────────────────────────────────
|
|
49
118
|
// Main export runner
|
|
50
119
|
// ──────────────────────────────────────────────
|
|
@@ -84,6 +153,7 @@ export async function runExport(outputDir, options) {
|
|
|
84
153
|
total: 0,
|
|
85
154
|
exported: 0,
|
|
86
155
|
failed: 0,
|
|
156
|
+
skipped: 0,
|
|
87
157
|
mediaDownloaded: 0,
|
|
88
158
|
duration: Date.now() - startTime,
|
|
89
159
|
},
|
|
@@ -98,6 +168,9 @@ export async function runExport(outputDir, options) {
|
|
|
98
168
|
}
|
|
99
169
|
const results = [];
|
|
100
170
|
let totalMediaDownloaded = 0;
|
|
171
|
+
// Output files written so far — a file blob whose original filename is
|
|
172
|
+
// already taken falls back to its KB path instead of overwriting.
|
|
173
|
+
const claimed = new Set();
|
|
101
174
|
for (const docItem of documents) {
|
|
102
175
|
const docStart = Date.now();
|
|
103
176
|
const mediaFiles = [];
|
|
@@ -115,7 +188,21 @@ export async function runExport(outputDir, options) {
|
|
|
115
188
|
// and the kb-import-markdown round-trip test (both use the clean export).
|
|
116
189
|
const doc = await client.getDocumentMarkdown(docItem.id);
|
|
117
190
|
if (!doc) {
|
|
118
|
-
|
|
191
|
+
// No grammar body: a report / file / slides document. Export its real
|
|
192
|
+
// body (never an empty `.md` stand-in).
|
|
193
|
+
const files = await exportNonMarkdownDocument(client, docItem.path, docItem.id, outputDir, options.dryRun, claimed);
|
|
194
|
+
files.forEach((f) => claimed.add(f));
|
|
195
|
+
console.error(` ✓ ${files[0]}`);
|
|
196
|
+
results.push({
|
|
197
|
+
documentId: docItem.id,
|
|
198
|
+
documentPath: docItem.path,
|
|
199
|
+
outputFile: files[0],
|
|
200
|
+
status: 'exported',
|
|
201
|
+
sectionsCount: 0,
|
|
202
|
+
mediaFiles: files.slice(1),
|
|
203
|
+
duration: Date.now() - docStart,
|
|
204
|
+
});
|
|
205
|
+
continue;
|
|
119
206
|
}
|
|
120
207
|
const mdRelativePath = docPathToFilePath(docItem.path);
|
|
121
208
|
const mdFullPath = path.join(outputDir, mdRelativePath);
|
|
@@ -171,6 +258,7 @@ export async function runExport(outputDir, options) {
|
|
|
171
258
|
fs.mkdirSync(path.dirname(mdFullPath), { recursive: true });
|
|
172
259
|
fs.writeFileSync(mdFullPath, markdown, 'utf-8');
|
|
173
260
|
}
|
|
261
|
+
claimed.add(mdRelativePath);
|
|
174
262
|
console.error(` ✓ ${mdRelativePath} (${mediaFiles.length} media)`);
|
|
175
263
|
results.push({
|
|
176
264
|
documentId: docItem.id,
|
|
@@ -186,12 +274,13 @@ export async function runExport(outputDir, options) {
|
|
|
186
274
|
}
|
|
187
275
|
catch (error) {
|
|
188
276
|
const message = error instanceof Error ? error.message : String(error);
|
|
189
|
-
|
|
277
|
+
const skipped = error instanceof SkipExport;
|
|
278
|
+
console.error(` ${skipped ? '-' : '✗'} ${skipped ? 'Skipped' : 'Failed'}: ${message}`);
|
|
190
279
|
results.push({
|
|
191
280
|
documentId: docItem.id,
|
|
192
281
|
documentPath: docItem.path,
|
|
193
|
-
outputFile:
|
|
194
|
-
status: 'failed',
|
|
282
|
+
outputFile: '',
|
|
283
|
+
status: skipped ? 'skipped' : 'failed',
|
|
195
284
|
sectionsCount: 0,
|
|
196
285
|
mediaFiles: [],
|
|
197
286
|
error: message,
|
|
@@ -215,6 +304,7 @@ export async function runExport(outputDir, options) {
|
|
|
215
304
|
total: results.length,
|
|
216
305
|
exported: results.filter((r) => r.status === 'exported').length,
|
|
217
306
|
failed: results.filter((r) => r.status === 'failed').length,
|
|
307
|
+
skipped: results.filter((r) => r.status === 'skipped').length,
|
|
218
308
|
mediaDownloaded: totalMediaDownloaded,
|
|
219
309
|
duration: Date.now() - startTime,
|
|
220
310
|
},
|
package/dist/export.test.js
CHANGED
|
@@ -105,3 +105,124 @@ describe('runExport — clean grammar (no {#id} anchors, Issue 1)', () => {
|
|
|
105
105
|
fs.rmSync(outputDir, { recursive: true, force: true });
|
|
106
106
|
});
|
|
107
107
|
});
|
|
108
|
+
/**
|
|
109
|
+
* Non-markdown kinds (prod 2026-09-25). A `kind='report'` document came out
|
|
110
|
+
* as a 34-byte `.md` holding only `# <name>` while the summary said
|
|
111
|
+
* `exported: 2, failed: 0` — its HTML body silently gone. The grammar export
|
|
112
|
+
* (`get_document_markdown`) has no body for report / file / slides, so each
|
|
113
|
+
* kind now exports its REAL body via the `read` tool, and anything that can't
|
|
114
|
+
* be exported is `skipped` or `failed` with a reason — never "exported".
|
|
115
|
+
*/
|
|
116
|
+
describe('runExport — non-markdown kinds export their real body', () => {
|
|
117
|
+
afterEach(() => {
|
|
118
|
+
vi.restoreAllMocks();
|
|
119
|
+
});
|
|
120
|
+
const OPTIONS = {
|
|
121
|
+
apiUrl: 'http://localhost:3001',
|
|
122
|
+
apiKey: 'test-key',
|
|
123
|
+
basePath: '',
|
|
124
|
+
imageDir: 'images',
|
|
125
|
+
pdfDir: 'pdfs',
|
|
126
|
+
csvDir: 'csvs',
|
|
127
|
+
dryRun: false,
|
|
128
|
+
};
|
|
129
|
+
function listItem(id, docPath) {
|
|
130
|
+
return {
|
|
131
|
+
id,
|
|
132
|
+
path: docPath,
|
|
133
|
+
name: docPath.split('/').pop(),
|
|
134
|
+
description: null,
|
|
135
|
+
createdAt: '2026-01-01T00:00:00.000Z',
|
|
136
|
+
updatedAt: null,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
it('writes report HTML, the file blob under its filename, and deck HTML; md stays grammar', async () => {
|
|
140
|
+
const outputDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kb-export-kinds-'));
|
|
141
|
+
vi.spyOn(MoxnClient.prototype, 'listDocuments').mockResolvedValue([
|
|
142
|
+
listItem('md-1', '/qa/notes'),
|
|
143
|
+
listItem('rep-1', '/qa/dashboard'),
|
|
144
|
+
listItem('file-1', '/qa/file-versions.txt'),
|
|
145
|
+
listItem('deck-1', '/qa/pitch'),
|
|
146
|
+
]);
|
|
147
|
+
vi.spyOn(MoxnClient.prototype, 'getDocumentMarkdown').mockImplementation(async (id) => id === 'md-1'
|
|
148
|
+
? {
|
|
149
|
+
id,
|
|
150
|
+
branchName: 'main',
|
|
151
|
+
markdown: '---\nname: notes\n---\n\nPreamble.\n\n## Alpha\n\nalpha\n',
|
|
152
|
+
}
|
|
153
|
+
: null);
|
|
154
|
+
const readDocument = vi
|
|
155
|
+
.spyOn(MoxnClient.prototype, 'readDocument')
|
|
156
|
+
.mockImplementation(async (id) => {
|
|
157
|
+
if (id === 'rep-1') {
|
|
158
|
+
return {
|
|
159
|
+
type: 'document',
|
|
160
|
+
documentId: id,
|
|
161
|
+
kind: 'report',
|
|
162
|
+
content: [{ type: 'text', text: '<h1>Q3</h1><p>numbers</p>' }],
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
if (id === 'file-1') {
|
|
166
|
+
return {
|
|
167
|
+
type: 'document',
|
|
168
|
+
documentId: id,
|
|
169
|
+
kind: 'file',
|
|
170
|
+
file: {
|
|
171
|
+
filename: 'f.txt',
|
|
172
|
+
download: { url: 'https://storage.example/signed-f' },
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
return {
|
|
177
|
+
type: 'document',
|
|
178
|
+
documentId: id,
|
|
179
|
+
kind: 'slides',
|
|
180
|
+
content: [{ type: 'text', text: '<section data-moxn-slide>one</section>' }],
|
|
181
|
+
deck: { themeCss: null },
|
|
182
|
+
};
|
|
183
|
+
});
|
|
184
|
+
vi.spyOn(MoxnClient.prototype, 'downloadFile').mockImplementation(async (url, dest) => {
|
|
185
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
186
|
+
fs.writeFileSync(dest, `blob from ${url}`);
|
|
187
|
+
});
|
|
188
|
+
const log = await runExport(outputDir, OPTIONS);
|
|
189
|
+
// md never touches the read fallback.
|
|
190
|
+
expect(readDocument.mock.calls.map((c) => c[0]).sort()).toEqual([
|
|
191
|
+
'deck-1',
|
|
192
|
+
'file-1',
|
|
193
|
+
'rep-1',
|
|
194
|
+
]);
|
|
195
|
+
expect(fs.readFileSync(path.join(outputDir, 'qa/notes.md'), 'utf-8')).toContain('Preamble.');
|
|
196
|
+
expect(fs.readFileSync(path.join(outputDir, 'qa/dashboard.html'), 'utf-8')).toBe('<h1>Q3</h1><p>numbers</p>');
|
|
197
|
+
expect(fs.readFileSync(path.join(outputDir, 'qa/f.txt'), 'utf-8')).toBe('blob from https://storage.example/signed-f');
|
|
198
|
+
expect(fs.readFileSync(path.join(outputDir, 'qa/pitch.html'), 'utf-8')).toBe('<section data-moxn-slide>one</section>');
|
|
199
|
+
// No empty `.md` stand-ins for the non-md kinds.
|
|
200
|
+
expect(fs.existsSync(path.join(outputDir, 'qa/dashboard.md'))).toBe(false);
|
|
201
|
+
const byId = Object.fromEntries(log.results.map((r) => [r.documentId, r]));
|
|
202
|
+
expect(byId['rep-1'].outputFile).toBe('qa/dashboard.html');
|
|
203
|
+
expect(byId['file-1'].outputFile).toBe('qa/f.txt');
|
|
204
|
+
expect(byId['deck-1'].outputFile).toBe('qa/pitch.html');
|
|
205
|
+
expect(log.summary).toMatchObject({ total: 4, exported: 4, failed: 0, skipped: 0 });
|
|
206
|
+
fs.rmSync(outputDir, { recursive: true, force: true });
|
|
207
|
+
});
|
|
208
|
+
it('a read error is a FAILURE with its reason, an unknown kind is SKIPPED — neither is "exported"', async () => {
|
|
209
|
+
const outputDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kb-export-kinds-err-'));
|
|
210
|
+
vi.spyOn(MoxnClient.prototype, 'listDocuments').mockResolvedValue([
|
|
211
|
+
listItem('locked', '/qa/locked'),
|
|
212
|
+
listItem('odd', '/qa/odd'),
|
|
213
|
+
]);
|
|
214
|
+
vi.spyOn(MoxnClient.prototype, 'getDocumentMarkdown').mockResolvedValue(null);
|
|
215
|
+
vi.spyOn(MoxnClient.prototype, 'readDocument').mockImplementation(async (id) => id === 'locked'
|
|
216
|
+
? { type: 'document', documentId: id, error: 'Access denied (aiAccess none)' }
|
|
217
|
+
: { type: 'document', documentId: id, kind: 'hologram' });
|
|
218
|
+
const log = await runExport(outputDir, OPTIONS);
|
|
219
|
+
const byId = Object.fromEntries(log.results.map((r) => [r.documentId, r]));
|
|
220
|
+
expect(byId.locked.status).toBe('failed');
|
|
221
|
+
expect(byId.locked.error).toContain('Access denied');
|
|
222
|
+
expect(byId.odd.status).toBe('skipped');
|
|
223
|
+
expect(byId.odd.error).toMatch(/hologram/);
|
|
224
|
+
expect(log.summary).toMatchObject({ total: 2, exported: 0, failed: 1, skipped: 1 });
|
|
225
|
+
expect(fs.readdirSync(outputDir).filter((f) => f === 'qa')).toEqual([]);
|
|
226
|
+
fs.rmSync(outputDir, { recursive: true, force: true });
|
|
227
|
+
});
|
|
228
|
+
});
|
package/dist/http.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function moxnFetch(url: string, init?: RequestInit): Promise<Response>;
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one fetch for Moxn API calls. Adds the Vercel protection-bypass header
|
|
3
|
+
* via @moxn/auth's `vercelBypassHeaders` — the helper kb-cli uses — which is a
|
|
4
|
+
* no-op unless MOXN_VERCEL_BYPASS is set AND the URL is MOXN_BASE_URL's
|
|
5
|
+
* origin, so signed storage / Notion / media URLs never receive the secret.
|
|
6
|
+
*/
|
|
7
|
+
import { vercelBypassHeaders } from '@moxn/auth';
|
|
8
|
+
export function moxnFetch(url, init = {}) {
|
|
9
|
+
const bypass = vercelBypassHeaders(url);
|
|
10
|
+
if (Object.keys(bypass).length === 0)
|
|
11
|
+
return fetch(url, init);
|
|
12
|
+
const headers = new Headers(init.headers);
|
|
13
|
+
for (const [k, v] of Object.entries(bypass))
|
|
14
|
+
headers.set(k, v);
|
|
15
|
+
return fetch(url, { ...init, headers });
|
|
16
|
+
}
|
package/dist/import-local.js
CHANGED
|
@@ -206,6 +206,7 @@ async function migrateGrammarFile(client, file, options) {
|
|
|
206
206
|
documentPath: res.path || documentPath,
|
|
207
207
|
status,
|
|
208
208
|
documentId: res.id,
|
|
209
|
+
...client.permissionWarningFor(res, file.sourcePath),
|
|
209
210
|
duration: Date.now() - startTime,
|
|
210
211
|
},
|
|
211
212
|
hadFmWarning,
|
|
@@ -117,3 +117,99 @@ describe('runLocalGrammarMigration — FM warning surface + summary count', () =
|
|
|
117
117
|
expect(log.summary.fmIgnoredOrDegraded).toBeUndefined();
|
|
118
118
|
});
|
|
119
119
|
});
|
|
120
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
121
|
+
// --default-permission / --ai-access reach the server (prod 2026-09-25: both
|
|
122
|
+
// were stored on the client and never sent, so `--ai-access none` created an
|
|
123
|
+
// ai_access='edit' document).
|
|
124
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
125
|
+
describe('runLocalGrammarMigration — importer permissions', () => {
|
|
126
|
+
let warnSpy;
|
|
127
|
+
let fetchSpy;
|
|
128
|
+
beforeEach(() => {
|
|
129
|
+
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
|
|
130
|
+
vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
131
|
+
fetchSpy = vi.spyOn(globalThis, 'fetch');
|
|
132
|
+
});
|
|
133
|
+
afterEach(() => {
|
|
134
|
+
vi.restoreAllMocks();
|
|
135
|
+
});
|
|
136
|
+
const PERM_OPTIONS = {
|
|
137
|
+
...BASE_OPTIONS,
|
|
138
|
+
defaultPermission: 'read',
|
|
139
|
+
aiAccess: 'none',
|
|
140
|
+
};
|
|
141
|
+
function sentBody() {
|
|
142
|
+
const init = fetchSpy.mock.calls[0][1];
|
|
143
|
+
return JSON.parse(String(init.body));
|
|
144
|
+
}
|
|
145
|
+
it('sends defaultPermission + aiAccess on the import_markdown request', async () => {
|
|
146
|
+
fetchSpy.mockResolvedValue(mockResponse(200, {
|
|
147
|
+
result: {
|
|
148
|
+
id: 'doc-p',
|
|
149
|
+
path: '/imported/my-doc',
|
|
150
|
+
outcome: 'created',
|
|
151
|
+
existed: false,
|
|
152
|
+
name: 'my-doc',
|
|
153
|
+
frontMatterIgnored: false,
|
|
154
|
+
permissions: { defaultPermission: 'read', aiAccess: 'none' },
|
|
155
|
+
},
|
|
156
|
+
}));
|
|
157
|
+
const log = await runLocalGrammarMigration(fakeSource(), PERM_OPTIONS);
|
|
158
|
+
const body = sentBody();
|
|
159
|
+
expect(body.action).toBe('import_markdown');
|
|
160
|
+
expect(body.defaultPermission).toBe('read');
|
|
161
|
+
expect(body.aiAccess).toBe('none');
|
|
162
|
+
expect(log.results[0].status).toBe('created');
|
|
163
|
+
// Applied as asked → nothing to warn about.
|
|
164
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
165
|
+
});
|
|
166
|
+
it('omits both fields when the flags were not given (server keeps its defaults)', async () => {
|
|
167
|
+
fetchSpy.mockResolvedValue(mockResponse(200, {
|
|
168
|
+
result: {
|
|
169
|
+
id: 'doc-d',
|
|
170
|
+
path: '/imported/my-doc',
|
|
171
|
+
outcome: 'created',
|
|
172
|
+
existed: false,
|
|
173
|
+
name: 'my-doc',
|
|
174
|
+
frontMatterIgnored: false,
|
|
175
|
+
permissions: { defaultPermission: 'read', aiAccess: 'edit' },
|
|
176
|
+
},
|
|
177
|
+
}));
|
|
178
|
+
await runLocalGrammarMigration(fakeSource(), BASE_OPTIONS);
|
|
179
|
+
const body = sentBody();
|
|
180
|
+
expect('defaultPermission' in body).toBe(false);
|
|
181
|
+
expect('aiAccess' in body).toBe(false);
|
|
182
|
+
});
|
|
183
|
+
it('warns that an UPDATED document kept its existing permissions', async () => {
|
|
184
|
+
fetchSpy.mockResolvedValue(mockResponse(200, {
|
|
185
|
+
result: {
|
|
186
|
+
id: 'doc-u',
|
|
187
|
+
path: '/imported/my-doc',
|
|
188
|
+
outcome: 'updated',
|
|
189
|
+
existed: true,
|
|
190
|
+
name: 'my-doc',
|
|
191
|
+
frontMatterIgnored: false,
|
|
192
|
+
},
|
|
193
|
+
}));
|
|
194
|
+
const log = await runLocalGrammarMigration(fakeSource(), PERM_OPTIONS);
|
|
195
|
+
expect(log.results[0].status).toBe('updated');
|
|
196
|
+
expect(log.results[0].warning).toMatch(/permissions/i);
|
|
197
|
+
expect(String(warnSpy.mock.calls[0]?.[0])).toMatch(/existing document.*permissions/i);
|
|
198
|
+
});
|
|
199
|
+
it('warns when a server too old to apply the flags created the document without them', async () => {
|
|
200
|
+
fetchSpy.mockResolvedValue(mockResponse(200, {
|
|
201
|
+
result: {
|
|
202
|
+
id: 'doc-o',
|
|
203
|
+
path: '/imported/my-doc',
|
|
204
|
+
outcome: 'created',
|
|
205
|
+
existed: false,
|
|
206
|
+
name: 'my-doc',
|
|
207
|
+
frontMatterIgnored: false,
|
|
208
|
+
// no `permissions` echo — an older server ignored the fields
|
|
209
|
+
},
|
|
210
|
+
}));
|
|
211
|
+
const log = await runLocalGrammarMigration(fakeSource(), PERM_OPTIONS);
|
|
212
|
+
expect(log.results[0].warning).toMatch(/server/i);
|
|
213
|
+
expect(String(warnSpy.mock.calls[0]?.[0])).toMatch(/did not apply/i);
|
|
214
|
+
});
|
|
215
|
+
});
|
package/dist/index.js
CHANGED
|
@@ -19,6 +19,7 @@ import { notionColorToHex } from './sources/notion-api.js';
|
|
|
19
19
|
import { getPageTitle } from './sources/notion-blocks.js';
|
|
20
20
|
import { slugify } from './sources/notion.js';
|
|
21
21
|
import { MoxnClient } from './client.js';
|
|
22
|
+
import { emitJson, enterJsonMode, exitJsonMode, packageVersion } from './output.js';
|
|
22
23
|
import { runExport } from './export.js';
|
|
23
24
|
import { runLocalGrammarMigration } from './import-local.js';
|
|
24
25
|
import { runNotionExport } from './export-notion.js';
|
|
@@ -121,6 +122,18 @@ async function runMigration(source, options) {
|
|
|
121
122
|
};
|
|
122
123
|
return log;
|
|
123
124
|
}
|
|
125
|
+
/** Exit on a --default-permission / --ai-access value the server would reject per document. */
|
|
126
|
+
function assertPermissionFlags(opts) {
|
|
127
|
+
for (const [flag, value] of [
|
|
128
|
+
['--default-permission', opts.defaultPermission],
|
|
129
|
+
['--ai-access', opts.aiAccess],
|
|
130
|
+
]) {
|
|
131
|
+
if (value !== undefined && !['edit', 'read', 'none'].includes(value)) {
|
|
132
|
+
console.error(`Error: ${flag} must be "edit", "read" or "none"`);
|
|
133
|
+
process.exit(1);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
124
137
|
function printSummary(log) {
|
|
125
138
|
console.log('\n--- Migration Summary ---');
|
|
126
139
|
console.log(`Source: ${log.source.type} (${log.source.location})`);
|
|
@@ -159,10 +172,14 @@ function printExportSummary(log) {
|
|
|
159
172
|
console.log(`Total: ${log.summary.total}`);
|
|
160
173
|
console.log(`Exported: ${log.summary.exported}`);
|
|
161
174
|
console.log(`Failed: ${log.summary.failed}`);
|
|
175
|
+
console.log(`Skipped: ${log.summary.skipped}`);
|
|
162
176
|
console.log(`Media: ${log.summary.mediaDownloaded}`);
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
177
|
+
for (const status of ['failed', 'skipped']) {
|
|
178
|
+
const rows = log.results.filter((r) => r.status === status);
|
|
179
|
+
if (rows.length === 0)
|
|
180
|
+
continue;
|
|
181
|
+
console.log(`\n${status === 'failed' ? 'Failed' : 'Skipped'} documents:`);
|
|
182
|
+
for (const f of rows) {
|
|
166
183
|
console.log(` - ${f.documentPath}: ${f.error}`);
|
|
167
184
|
}
|
|
168
185
|
}
|
|
@@ -276,7 +293,15 @@ const program = new Command();
|
|
|
276
293
|
program
|
|
277
294
|
.name('moxn-kb-migrate')
|
|
278
295
|
.description('Import documents into Moxn Knowledge Base')
|
|
279
|
-
.version(
|
|
296
|
+
.version(packageVersion());
|
|
297
|
+
// Which kb-migrate ran is always on stderr (a stale npx resolution once ran a
|
|
298
|
+
// retired exporter silently), and `--json` keeps stdout to the JSON log alone.
|
|
299
|
+
program.hook('preAction', (_program, actionCommand) => {
|
|
300
|
+
console.error(`@moxn/kb-migrate ${packageVersion()}`);
|
|
301
|
+
if (actionCommand.opts().json)
|
|
302
|
+
enterJsonMode();
|
|
303
|
+
});
|
|
304
|
+
program.hook('postAction', () => exitJsonMode());
|
|
280
305
|
program
|
|
281
306
|
.command('local <directory>')
|
|
282
307
|
.description('Migrate documents from local filesystem')
|
|
@@ -285,8 +310,8 @@ program
|
|
|
285
310
|
.option('--base-path <path>', 'Base path for imported documents', '/')
|
|
286
311
|
.option('--extensions <exts>', 'Comma-separated file extensions', DEFAULT_EXTENSIONS.join(','))
|
|
287
312
|
.option('--on-conflict <action>', 'Action on conflict: skip or update', 'skip')
|
|
288
|
-
.option('--default-permission <perm>', 'Default permission: edit, read, or none')
|
|
289
|
-
.option('--ai-access <perm>', 'AI access
|
|
313
|
+
.option('--default-permission <perm>', 'Default permission for CREATED docs: edit, read, or none (an update leaves existing permissions alone)')
|
|
314
|
+
.option('--ai-access <perm>', 'AI access for CREATED docs: edit, read, or none (an update leaves existing permissions alone)')
|
|
290
315
|
.option('--created-after <date>', 'Only include docs created after this date (ISO 8601)')
|
|
291
316
|
.option('--created-before <date>', 'Only include docs created before this date (ISO 8601)')
|
|
292
317
|
.option('--modified-after <date>', 'Only include docs modified after this date (ISO 8601)')
|
|
@@ -305,6 +330,7 @@ program
|
|
|
305
330
|
console.error('Error: --on-conflict must be "skip" or "update"');
|
|
306
331
|
process.exit(1);
|
|
307
332
|
}
|
|
333
|
+
assertPermissionFlags(opts);
|
|
308
334
|
const dateFilter = buildDateFilter({
|
|
309
335
|
createdAfter: opts.createdAfter,
|
|
310
336
|
createdBefore: opts.createdBefore,
|
|
@@ -333,7 +359,7 @@ program
|
|
|
333
359
|
// create_document. Notion/OneNote stay on the blocks runMigration path.
|
|
334
360
|
const log = await runLocalGrammarMigration(source, migrationOptions);
|
|
335
361
|
if (opts.json) {
|
|
336
|
-
|
|
362
|
+
emitJson(log);
|
|
337
363
|
}
|
|
338
364
|
else {
|
|
339
365
|
printSummary(log);
|
|
@@ -388,7 +414,7 @@ program
|
|
|
388
414
|
try {
|
|
389
415
|
const log = await runExport(directory, exportOptions);
|
|
390
416
|
if (opts.json) {
|
|
391
|
-
|
|
417
|
+
emitJson(log);
|
|
392
418
|
}
|
|
393
419
|
else {
|
|
394
420
|
printExportSummary(log);
|
|
@@ -412,8 +438,8 @@ program
|
|
|
412
438
|
.option('--root-page-id <id>', 'Import subtree starting from this Notion page ID')
|
|
413
439
|
.option('--max-depth <n>', 'Maximum nesting depth')
|
|
414
440
|
.option('--on-conflict <action>', 'Action on conflict: skip or update', 'skip')
|
|
415
|
-
.option('--default-permission <perm>', 'Default permission: edit, read, or none')
|
|
416
|
-
.option('--ai-access <perm>', 'AI access
|
|
441
|
+
.option('--default-permission <perm>', 'Default permission for CREATED docs: edit, read, or none (an update leaves existing permissions alone)')
|
|
442
|
+
.option('--ai-access <perm>', 'AI access for CREATED docs: edit, read, or none (an update leaves existing permissions alone)')
|
|
417
443
|
.option('--visibility <vis>', 'Convenience flag: team (read) or private (none)')
|
|
418
444
|
.option('--created-after <date>', 'Only include docs created after this date (ISO 8601)')
|
|
419
445
|
.option('--created-before <date>', 'Only include docs created before this date (ISO 8601)')
|
|
@@ -437,6 +463,7 @@ program
|
|
|
437
463
|
console.error('Error: --on-conflict must be "skip" or "update"');
|
|
438
464
|
process.exit(1);
|
|
439
465
|
}
|
|
466
|
+
assertPermissionFlags(opts);
|
|
440
467
|
// Resolve visibility → defaultPermission (visibility is syntactic sugar)
|
|
441
468
|
let defaultPermission = opts.defaultPermission;
|
|
442
469
|
if (!defaultPermission && opts.visibility) {
|
|
@@ -650,7 +677,7 @@ program
|
|
|
650
677
|
// Cleanup temp files
|
|
651
678
|
await source.cleanup();
|
|
652
679
|
if (opts.json) {
|
|
653
|
-
|
|
680
|
+
emitJson(log);
|
|
654
681
|
}
|
|
655
682
|
else {
|
|
656
683
|
printSummary(log);
|
|
@@ -718,7 +745,7 @@ program
|
|
|
718
745
|
dateFilter,
|
|
719
746
|
});
|
|
720
747
|
if (opts.json) {
|
|
721
|
-
|
|
748
|
+
emitJson(log);
|
|
722
749
|
}
|
|
723
750
|
else {
|
|
724
751
|
printNotionExportSummary(log);
|
package/dist/output.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This package's version, read from its package.json at runtime (src/ and
|
|
3
|
+
* dist/ both sit one level below it). Reported by `--version`, on stderr at
|
|
4
|
+
* the start of every command, and on every JSON log — so a stale npx
|
|
5
|
+
* resolution of an old kb-migrate is visible instead of silent.
|
|
6
|
+
*/
|
|
7
|
+
export declare function packageVersion(): string;
|
|
8
|
+
/**
|
|
9
|
+
* Route console.log to stderr until {@link exitJsonMode}. The CLI enters it
|
|
10
|
+
* from a preAction hook for any command run with `--json`.
|
|
11
|
+
*/
|
|
12
|
+
export declare function enterJsonMode(): void;
|
|
13
|
+
export declare function exitJsonMode(): void;
|
|
14
|
+
/** Run `fn` with console.log routed to stderr when `json` is set. */
|
|
15
|
+
export declare function withJsonStdout<T>(json: boolean, fn: () => Promise<T>): Promise<T>;
|
|
16
|
+
/** Write a command's JSON log to stdout, stamped with the kb-migrate version. */
|
|
17
|
+
export declare function emitJson(log: object): void;
|
package/dist/output.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process-output helpers for the kb-migrate CLI.
|
|
3
|
+
*
|
|
4
|
+
* `--json` promises stdout is ONE JSON document — `context import-local
|
|
5
|
+
* --json` and friends parse it. Progress lines are printed with console.log
|
|
6
|
+
* all through the runners, so JSON mode routes console.log to stderr for the
|
|
7
|
+
* duration of the run and writes the log itself straight to stdout.
|
|
8
|
+
*/
|
|
9
|
+
import * as fs from 'fs';
|
|
10
|
+
import { format } from 'util';
|
|
11
|
+
let cachedVersion;
|
|
12
|
+
/**
|
|
13
|
+
* This package's version, read from its package.json at runtime (src/ and
|
|
14
|
+
* dist/ both sit one level below it). Reported by `--version`, on stderr at
|
|
15
|
+
* the start of every command, and on every JSON log — so a stale npx
|
|
16
|
+
* resolution of an old kb-migrate is visible instead of silent.
|
|
17
|
+
*/
|
|
18
|
+
export function packageVersion() {
|
|
19
|
+
if (cachedVersion === undefined) {
|
|
20
|
+
try {
|
|
21
|
+
const pkg = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf-8'));
|
|
22
|
+
cachedVersion = pkg.version ?? 'unknown';
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
cachedVersion = 'unknown';
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return cachedVersion;
|
|
29
|
+
}
|
|
30
|
+
let restoreLog;
|
|
31
|
+
/**
|
|
32
|
+
* Route console.log to stderr until {@link exitJsonMode}. The CLI enters it
|
|
33
|
+
* from a preAction hook for any command run with `--json`.
|
|
34
|
+
*/
|
|
35
|
+
export function enterJsonMode() {
|
|
36
|
+
if (restoreLog)
|
|
37
|
+
return;
|
|
38
|
+
const original = console.log;
|
|
39
|
+
console.log = (...args) => {
|
|
40
|
+
process.stderr.write(`${format(...args)}\n`);
|
|
41
|
+
};
|
|
42
|
+
restoreLog = () => {
|
|
43
|
+
console.log = original;
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
export function exitJsonMode() {
|
|
47
|
+
restoreLog?.();
|
|
48
|
+
restoreLog = undefined;
|
|
49
|
+
}
|
|
50
|
+
/** Run `fn` with console.log routed to stderr when `json` is set. */
|
|
51
|
+
export async function withJsonStdout(json, fn) {
|
|
52
|
+
if (!json)
|
|
53
|
+
return fn();
|
|
54
|
+
enterJsonMode();
|
|
55
|
+
try {
|
|
56
|
+
return await fn();
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
exitJsonMode();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/** Write a command's JSON log to stdout, stamped with the kb-migrate version. */
|
|
63
|
+
export function emitJson(log) {
|
|
64
|
+
process.stdout.write(`${JSON.stringify({ kbMigrateVersion: packageVersion(), ...log }, null, 2)}\n`);
|
|
65
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|