@moxn/kb-migrate 0.4.38 → 0.4.40
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/client.d.ts +6 -0
- package/dist/import-local.js +37 -17
- package/dist/import-local.test.js +102 -2
- package/dist/index.js +3 -0
- package/dist/sources/local.js +3 -1
- package/dist/sources/notion-blocks.js +1 -1
- package/dist/sources/notion.d.ts +1 -1
- package/dist/sources/notion.js +4 -4
- package/dist/sources/notion.test.js +13 -0
- package/dist/sources/onenote/onenote-api.d.ts +1 -0
- package/dist/sources/onenote/onenote-tree.js +13 -2
- package/dist/types.d.ts +6 -0
- package/package.json +1 -1
package/dist/client.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* API client for Moxn KB
|
|
3
3
|
*/
|
|
4
|
+
/// <reference types="node" resolution-mode="require"/>
|
|
4
5
|
import type { ExtractedDocument, MigrationOptions, MigrationResult, DocumentListItem, DocumentDetail, ExportOptions } from './types.js';
|
|
5
6
|
import type { DateFilter } from './date-filter.js';
|
|
6
7
|
/**
|
|
@@ -27,6 +28,11 @@ export interface ImportMarkdownResult {
|
|
|
27
28
|
name?: string;
|
|
28
29
|
/** True when a front-matter block was present but NOT applied (no name → body-only import). */
|
|
29
30
|
frontMatterIgnored?: boolean;
|
|
31
|
+
/**
|
|
32
|
+
* Set when strict YAML parsing of the front-matter block failed, even if the
|
|
33
|
+
* lenient fallback recovered the name. Carries the js-yaml error message.
|
|
34
|
+
*/
|
|
35
|
+
frontMatterError?: string;
|
|
30
36
|
}
|
|
31
37
|
export declare class MoxnClient {
|
|
32
38
|
private apiUrl;
|
package/dist/import-local.js
CHANGED
|
@@ -100,13 +100,16 @@ export async function runLocalGrammarMigration(source, options) {
|
|
|
100
100
|
const client = new MoxnClient(options);
|
|
101
101
|
let processed = 0;
|
|
102
102
|
let consecutiveFailures = 0;
|
|
103
|
+
let fmIgnoredOrDegraded = 0;
|
|
103
104
|
const MAX_CONSECUTIVE_FAILURES = 10;
|
|
104
105
|
for await (const file of source.extractGrammarFiles()) {
|
|
105
106
|
processed++;
|
|
106
107
|
const progress = totalCount ? ` (${processed}/${totalCount})` : '';
|
|
107
108
|
console.log(`Processing: ${file.sourcePath}${progress}`);
|
|
108
|
-
const result = await migrateGrammarFile(client, file, options);
|
|
109
|
+
const { result, hadFmWarning } = await migrateGrammarFile(client, file, options);
|
|
109
110
|
results.push(result);
|
|
111
|
+
if (hadFmWarning)
|
|
112
|
+
fmIgnoredOrDegraded++;
|
|
110
113
|
if (result.status === 'failed') {
|
|
111
114
|
consecutiveFailures++;
|
|
112
115
|
}
|
|
@@ -138,6 +141,7 @@ export async function runLocalGrammarMigration(source, options) {
|
|
|
138
141
|
skipped: results.filter((r) => r.status === 'skipped').length,
|
|
139
142
|
failed: results.filter((r) => r.status === 'failed').length,
|
|
140
143
|
duration: Date.now() - startTime,
|
|
144
|
+
...(fmIgnoredOrDegraded > 0 ? { fmIgnoredOrDegraded } : {}),
|
|
141
145
|
};
|
|
142
146
|
return {
|
|
143
147
|
timestamp: new Date().toISOString(),
|
|
@@ -155,10 +159,13 @@ async function migrateGrammarFile(client, file, options) {
|
|
|
155
159
|
const documentPath = joinKbPath(options.basePath, file.kbPath);
|
|
156
160
|
if (options.dryRun) {
|
|
157
161
|
return {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
+
result: {
|
|
163
|
+
sourcePath: file.sourcePath,
|
|
164
|
+
documentPath,
|
|
165
|
+
status: 'skipped',
|
|
166
|
+
duration: Date.now() - startTime,
|
|
167
|
+
},
|
|
168
|
+
hadFmWarning: false,
|
|
162
169
|
};
|
|
163
170
|
}
|
|
164
171
|
try {
|
|
@@ -175,11 +182,18 @@ async function migrateGrammarFile(client, file, options) {
|
|
|
175
182
|
onConflict: options.onConflict,
|
|
176
183
|
});
|
|
177
184
|
// Surface a non-fatal warning when the file carried a front-matter block
|
|
178
|
-
// that wasn't applied (no `name` → body-only import)
|
|
179
|
-
//
|
|
185
|
+
// that wasn't applied (no `name` → body-only import), OR when strict YAML
|
|
186
|
+
// parsing failed (even if the lenient fallback recovered the name, some
|
|
187
|
+
// metadata like tags/properties may have been lost).
|
|
188
|
+
let hadFmWarning = false;
|
|
180
189
|
if (res.frontMatterIgnored) {
|
|
190
|
+
hadFmWarning = true;
|
|
181
191
|
console.warn(` ⚠ front-matter present but not applied (no \`name\`) — imported body only: ${file.sourcePath}`);
|
|
182
192
|
}
|
|
193
|
+
else if (res.frontMatterError) {
|
|
194
|
+
hadFmWarning = true;
|
|
195
|
+
console.warn(` ⚠ front-matter YAML parse error (tags/properties may be missing): ${res.frontMatterError} — ${file.sourcePath}`);
|
|
196
|
+
}
|
|
183
197
|
// Map import_markdown outcome → migration status.
|
|
184
198
|
const status = res.outcome === 'created'
|
|
185
199
|
? 'created'
|
|
@@ -187,20 +201,26 @@ async function migrateGrammarFile(client, file, options) {
|
|
|
187
201
|
? 'updated'
|
|
188
202
|
: 'skipped';
|
|
189
203
|
return {
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
204
|
+
result: {
|
|
205
|
+
sourcePath: file.sourcePath,
|
|
206
|
+
documentPath: res.path || documentPath,
|
|
207
|
+
status,
|
|
208
|
+
documentId: res.id,
|
|
209
|
+
duration: Date.now() - startTime,
|
|
210
|
+
},
|
|
211
|
+
hadFmWarning,
|
|
195
212
|
};
|
|
196
213
|
}
|
|
197
214
|
catch (error) {
|
|
198
215
|
return {
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
216
|
+
result: {
|
|
217
|
+
sourcePath: file.sourcePath,
|
|
218
|
+
documentPath,
|
|
219
|
+
status: 'failed',
|
|
220
|
+
error: error instanceof Error ? error.message : 'Unknown error',
|
|
221
|
+
duration: Date.now() - startTime,
|
|
222
|
+
},
|
|
223
|
+
hadFmWarning: false,
|
|
204
224
|
};
|
|
205
225
|
}
|
|
206
226
|
}
|
|
@@ -1,5 +1,9 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { joinKbPath } from './import-local.js';
|
|
1
|
+
import { describe, it, expect, vi, afterEach, beforeEach, } from 'vitest';
|
|
2
|
+
import { joinKbPath, runLocalGrammarMigration } from './import-local.js';
|
|
3
|
+
// Mock fs/promises at module level so readFile is consistently stubable.
|
|
4
|
+
vi.mock('fs/promises', () => ({
|
|
5
|
+
readFile: vi.fn().mockResolvedValue('---\nname: test-doc\n---\n\n## Body\n\ncontent'),
|
|
6
|
+
}));
|
|
3
7
|
describe('joinKbPath', () => {
|
|
4
8
|
it('prefixes the relative KB path under a base path', () => {
|
|
5
9
|
expect(joinKbPath('/imported', 'subdir/doc')).toBe('/imported/subdir/doc');
|
|
@@ -17,3 +21,99 @@ describe('joinKbPath', () => {
|
|
|
17
21
|
expect(joinKbPath('base', 'doc')).toBe('/base/doc');
|
|
18
22
|
});
|
|
19
23
|
});
|
|
24
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
25
|
+
// FM warning + summary count tests
|
|
26
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
27
|
+
const BASE_OPTIONS = {
|
|
28
|
+
apiUrl: 'http://test-api',
|
|
29
|
+
apiKey: 'test-key',
|
|
30
|
+
basePath: '/imported',
|
|
31
|
+
onConflict: 'update',
|
|
32
|
+
dryRun: false,
|
|
33
|
+
};
|
|
34
|
+
function mockResponse(status, body) {
|
|
35
|
+
return {
|
|
36
|
+
ok: status >= 200 && status < 300,
|
|
37
|
+
status,
|
|
38
|
+
json: async () => body,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
function fakeSource(kbPath = 'my-doc.md') {
|
|
42
|
+
return {
|
|
43
|
+
sourceType: 'local',
|
|
44
|
+
sourceLocation: '/fake',
|
|
45
|
+
validate: async () => { },
|
|
46
|
+
getDocumentCount: async () => 1,
|
|
47
|
+
async *extractGrammarFiles() {
|
|
48
|
+
yield {
|
|
49
|
+
fullPath: '/fake/my-doc.md',
|
|
50
|
+
sourcePath: 'my-doc.md',
|
|
51
|
+
kbPath,
|
|
52
|
+
};
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
describe('runLocalGrammarMigration — FM warning surface + summary count', () => {
|
|
57
|
+
let warnSpy;
|
|
58
|
+
let logSpy;
|
|
59
|
+
let fetchSpy;
|
|
60
|
+
beforeEach(() => {
|
|
61
|
+
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
|
|
62
|
+
logSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
63
|
+
fetchSpy = vi.spyOn(globalThis, 'fetch');
|
|
64
|
+
});
|
|
65
|
+
afterEach(() => {
|
|
66
|
+
vi.restoreAllMocks();
|
|
67
|
+
});
|
|
68
|
+
it('includes frontMatterError text in the console warning when server returns it', async () => {
|
|
69
|
+
fetchSpy.mockResolvedValue(mockResponse(200, {
|
|
70
|
+
result: {
|
|
71
|
+
id: 'doc-1',
|
|
72
|
+
path: '/imported/test',
|
|
73
|
+
outcome: 'created',
|
|
74
|
+
existed: false,
|
|
75
|
+
name: 'test',
|
|
76
|
+
frontMatterIgnored: false,
|
|
77
|
+
frontMatterError: 'strict YAML parse failed (bad indentation of a mapping entry); recovered name/description via lenient fallback',
|
|
78
|
+
},
|
|
79
|
+
}));
|
|
80
|
+
const log = await runLocalGrammarMigration(fakeSource(), BASE_OPTIONS);
|
|
81
|
+
// Warning must be emitted and include the frontMatterError message
|
|
82
|
+
expect(warnSpy).toHaveBeenCalledOnce();
|
|
83
|
+
const warnMsg = String(warnSpy.mock.calls[0][0]);
|
|
84
|
+
expect(warnMsg).toMatch(/YAML parse error|frontMatterError|bad indentation/i);
|
|
85
|
+
// Summary must carry the count
|
|
86
|
+
expect(log.summary.fmIgnoredOrDegraded).toBe(1);
|
|
87
|
+
void logSpy; // suppress unused warning
|
|
88
|
+
});
|
|
89
|
+
it('includes frontMatterIgnored warning when server returns frontMatterIgnored (no frontMatterError)', async () => {
|
|
90
|
+
fetchSpy.mockResolvedValue(mockResponse(200, {
|
|
91
|
+
result: {
|
|
92
|
+
id: 'doc-2',
|
|
93
|
+
path: '/imported/my-doc',
|
|
94
|
+
outcome: 'created',
|
|
95
|
+
existed: false,
|
|
96
|
+
name: 'my-doc',
|
|
97
|
+
frontMatterIgnored: true,
|
|
98
|
+
},
|
|
99
|
+
}));
|
|
100
|
+
const log = await runLocalGrammarMigration(fakeSource(), BASE_OPTIONS);
|
|
101
|
+
expect(warnSpy).toHaveBeenCalledOnce();
|
|
102
|
+
expect(log.summary.fmIgnoredOrDegraded).toBe(1);
|
|
103
|
+
});
|
|
104
|
+
it('fmIgnoredOrDegraded is absent from summary when no FM issues occurred', async () => {
|
|
105
|
+
fetchSpy.mockResolvedValue(mockResponse(200, {
|
|
106
|
+
result: {
|
|
107
|
+
id: 'doc-3',
|
|
108
|
+
path: '/imported/clean',
|
|
109
|
+
outcome: 'created',
|
|
110
|
+
existed: false,
|
|
111
|
+
name: 'clean',
|
|
112
|
+
frontMatterIgnored: false,
|
|
113
|
+
},
|
|
114
|
+
}));
|
|
115
|
+
const log = await runLocalGrammarMigration(fakeSource(), BASE_OPTIONS);
|
|
116
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
117
|
+
expect(log.summary.fmIgnoredOrDegraded).toBeUndefined();
|
|
118
|
+
});
|
|
119
|
+
});
|
package/dist/index.js
CHANGED
|
@@ -142,6 +142,9 @@ function printSummary(log) {
|
|
|
142
142
|
console.log(`Updated: ${log.summary.updated}`);
|
|
143
143
|
console.log(`Skipped: ${log.summary.skipped}`);
|
|
144
144
|
console.log(`Failed: ${log.summary.failed}`);
|
|
145
|
+
if (log.summary.fmIgnoredOrDegraded) {
|
|
146
|
+
console.log(`FM ignored: ${log.summary.fmIgnoredOrDegraded} (see warnings above)`);
|
|
147
|
+
}
|
|
145
148
|
if (log.options.dryRun) {
|
|
146
149
|
console.log('\n(Dry run - no changes made)');
|
|
147
150
|
}
|
package/dist/sources/local.js
CHANGED
|
@@ -7,7 +7,9 @@ import * as fs from 'fs/promises';
|
|
|
7
7
|
import * as fsSync from 'fs';
|
|
8
8
|
import * as path from 'path';
|
|
9
9
|
import { glob } from 'glob';
|
|
10
|
-
|
|
10
|
+
// unified@9 is CJS with a default-function export — a named import fails both
|
|
11
|
+
// tsc (TS2595 under esModuleInterop) and runtime ESM loading under tsx.
|
|
12
|
+
import unified from 'unified';
|
|
11
13
|
import remarkParse from 'remark-parse';
|
|
12
14
|
import { MigrationSource } from './base.js';
|
|
13
15
|
/**
|
package/dist/sources/notion.d.ts
CHANGED
|
@@ -150,7 +150,7 @@ export declare function slugify(title: string): string;
|
|
|
150
150
|
* so a second same-titled sibling was rejected by that assert (audit finding D).
|
|
151
151
|
* Mutates `siblingSlugCounts` (keyed on the BASE slug) the way the builder expects.
|
|
152
152
|
*/
|
|
153
|
-
export declare function disambiguateSibling(title: string, siblingSlugCounts: Map<string, number
|
|
153
|
+
export declare function disambiguateSibling(title: string, siblingSlugCounts: Map<string, number>, slugifyFn?: (s: string) => string): {
|
|
154
154
|
name: string;
|
|
155
155
|
slug: string;
|
|
156
156
|
};
|
package/dist/sources/notion.js
CHANGED
|
@@ -13,7 +13,7 @@ import { blocksToSections, getPageTitle, normalizeId, } from './notion-blocks.js
|
|
|
13
13
|
import { NotionMediaDownloader } from './notion-media.js';
|
|
14
14
|
import { parseDatabaseSchema, parseEntryValues, renderPropertiesSection, } from './notion-databases.js';
|
|
15
15
|
import { resolveNotionReferences } from './notion-references.js';
|
|
16
|
-
const MAX_DOCUMENT_COUNT =
|
|
16
|
+
const MAX_DOCUMENT_COUNT = 10000;
|
|
17
17
|
// ============================================
|
|
18
18
|
// Source
|
|
19
19
|
// ============================================
|
|
@@ -632,12 +632,12 @@ export function slugify(title) {
|
|
|
632
632
|
* so a second same-titled sibling was rejected by that assert (audit finding D).
|
|
633
633
|
* Mutates `siblingSlugCounts` (keyed on the BASE slug) the way the builder expects.
|
|
634
634
|
*/
|
|
635
|
-
export function disambiguateSibling(title, siblingSlugCounts) {
|
|
636
|
-
const baseSlug =
|
|
635
|
+
export function disambiguateSibling(title, siblingSlugCounts, slugifyFn = slugify) {
|
|
636
|
+
const baseSlug = slugifyFn(title);
|
|
637
637
|
const existing = siblingSlugCounts.get(baseSlug) ?? 0;
|
|
638
638
|
siblingSlugCounts.set(baseSlug, existing + 1);
|
|
639
639
|
if (existing === 0)
|
|
640
640
|
return { name: title, slug: baseSlug };
|
|
641
641
|
const name = `${title} ${existing + 1}`;
|
|
642
|
-
return { name, slug:
|
|
642
|
+
return { name, slug: slugifyFn(name) };
|
|
643
643
|
}
|
|
@@ -68,3 +68,16 @@ describe('disambiguateSibling (D — dedup name AND path together)', () => {
|
|
|
68
68
|
expect(disambiguateSibling('Beta', counts).slug).toBe('beta');
|
|
69
69
|
});
|
|
70
70
|
});
|
|
71
|
+
describe('disambiguateSibling — custom slugify (OneNote uses slugifyPathSegment)', () => {
|
|
72
|
+
const upperSlug = (s) => s.toUpperCase().replace(/ /g, '_');
|
|
73
|
+
it('derives the slug with the provided function, keeping the bijection', () => {
|
|
74
|
+
const counts = new Map();
|
|
75
|
+
expect(disambiguateSibling('My Page', counts, upperSlug)).toEqual({
|
|
76
|
+
name: 'My Page',
|
|
77
|
+
slug: 'MY_PAGE',
|
|
78
|
+
});
|
|
79
|
+
const second = disambiguateSibling('My Page', counts, upperSlug);
|
|
80
|
+
expect(second).toEqual({ name: 'My Page 2', slug: 'MY_PAGE_2' });
|
|
81
|
+
expect(upperSlug(second.name)).toBe(second.slug);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* are simple enough that a hand-rolled client is cheaper than the SDK and
|
|
7
7
|
* gives us precise control over 429 handling.
|
|
8
8
|
*/
|
|
9
|
+
/// <reference types="node" resolution-mode="require"/>
|
|
9
10
|
import type { OneNoteNotebook, OneNotePage, OneNoteSection, OneNoteSectionGroup } from './types.js';
|
|
10
11
|
export interface OneNoteApiClientOptions {
|
|
11
12
|
/** Alternate base URL — for testing only. */
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* own ID or any ancestor's ID is in the set. If the set is empty, every
|
|
12
12
|
* page is included.
|
|
13
13
|
*/
|
|
14
|
+
import { disambiguateSibling } from '../notion.js';
|
|
14
15
|
const MAX_GROUP_DEPTH = 4;
|
|
15
16
|
/**
|
|
16
17
|
* Normalize any human text into a ltree-safe slug segment.
|
|
@@ -96,12 +97,22 @@ async function walkSection(client, section, pathSegs, ctx, ancestorSelected, sel
|
|
|
96
97
|
createdBefore: opts.createdBefore,
|
|
97
98
|
});
|
|
98
99
|
const sectionSeg = slugifyPathSegment(section.displayName);
|
|
100
|
+
// Dedup same-titled sibling pages within a section — suffix the title AND the
|
|
101
|
+
// path segment together so slugify(name) === pathTail (the server's hard
|
|
102
|
+
// bijection assert) and two pages never collide on one path. Without this,
|
|
103
|
+
// two "Untitled" pages produce the same kbPath → the second silently clobbers
|
|
104
|
+
// or skips the first on import. (Notion finding D; OneNote had no dedup at all.)
|
|
105
|
+
const pageSlugCounts = new Map();
|
|
99
106
|
for (const page of pages) {
|
|
100
107
|
const pageSelected = ancestorSelected || (!!sel && sel.has(page.id));
|
|
101
108
|
if (!pageSelected)
|
|
102
109
|
continue;
|
|
103
|
-
const pageSeg =
|
|
104
|
-
out.push(toDiscovered(page,
|
|
110
|
+
const { name: dedupTitle, slug: pageSeg } = disambiguateSibling(page.title ?? 'untitled', pageSlugCounts, slugifyPathSegment);
|
|
111
|
+
out.push(toDiscovered({ ...page, title: dedupTitle }, section, [
|
|
112
|
+
...pathSegs,
|
|
113
|
+
sectionSeg,
|
|
114
|
+
pageSeg,
|
|
115
|
+
], ctx));
|
|
105
116
|
}
|
|
106
117
|
}
|
|
107
118
|
function toDiscovered(page, section, segs, ctx) {
|
package/dist/types.d.ts
CHANGED
|
@@ -176,6 +176,12 @@ export interface MigrationLog {
|
|
|
176
176
|
discovered?: number;
|
|
177
177
|
/** Pages skipped during extraction (empty, no content, errors). */
|
|
178
178
|
skippedDuringExtraction?: number;
|
|
179
|
+
/**
|
|
180
|
+
* Number of files where front-matter was ignored (no name) or degraded
|
|
181
|
+
* (strict YAML failed but lenient fallback recovered a name). Non-zero means
|
|
182
|
+
* some files lost tags/description/properties declared in the front-matter.
|
|
183
|
+
*/
|
|
184
|
+
fmIgnoredOrDegraded?: number;
|
|
179
185
|
};
|
|
180
186
|
}
|
|
181
187
|
/**
|
package/package.json
CHANGED