@moxn/kb-migrate 0.4.21 → 0.4.23
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/sources/notion.d.ts +36 -3
- package/dist/sources/notion.js +195 -120
- package/package.json +1 -1
package/dist/sources/notion.d.ts
CHANGED
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import type { ExtractedDocument } from '../types.js';
|
|
11
11
|
import { MigrationSource, type SourceConfig } from './base.js';
|
|
12
|
-
import type { NotionPage } from './notion-api.js';
|
|
12
|
+
import type { NotionPage, NotionDatabase } from './notion-api.js';
|
|
13
|
+
import { type PagePathMap } from './notion-blocks.js';
|
|
13
14
|
import { type ParsedDatabaseSchema } from './notion-databases.js';
|
|
14
15
|
export interface NotionSourceConfig extends SourceConfig {
|
|
15
16
|
token: string;
|
|
@@ -18,6 +19,16 @@ export interface NotionSourceConfig extends SourceConfig {
|
|
|
18
19
|
/** Date filter for source pages */
|
|
19
20
|
dateFilter?: import('../date-filter.js').DateFilter;
|
|
20
21
|
}
|
|
22
|
+
export interface PageTreeNode {
|
|
23
|
+
page: NotionPage;
|
|
24
|
+
title: string;
|
|
25
|
+
slug: string;
|
|
26
|
+
children: PageTreeNode[];
|
|
27
|
+
kbPath: string;
|
|
28
|
+
isDatabaseEntry: boolean;
|
|
29
|
+
isDatabaseNode?: boolean;
|
|
30
|
+
parentDatabaseId?: string;
|
|
31
|
+
}
|
|
21
32
|
/** Info yielded to the migration runner for database import (post-document pass). */
|
|
22
33
|
export interface NotionDatabaseImportInfo {
|
|
23
34
|
notionDatabaseId: string;
|
|
@@ -62,14 +73,36 @@ export declare class NotionSource extends MigrationSource<NotionSourceConfig> {
|
|
|
62
73
|
extract(): AsyncGenerator<ExtractedDocument, void, unknown>;
|
|
63
74
|
/** Clean up temp files after migration completes. */
|
|
64
75
|
cleanup(): Promise<void>;
|
|
65
|
-
private
|
|
66
|
-
private getParentPageId;
|
|
76
|
+
private buildPageTreeInternal;
|
|
67
77
|
private processDatabases;
|
|
68
78
|
private getDatabaseParentId;
|
|
69
79
|
private extractPage;
|
|
70
80
|
private extractDatabaseEntry;
|
|
71
81
|
private downloadSectionMedia;
|
|
72
82
|
}
|
|
83
|
+
/** Options for building the page tree. */
|
|
84
|
+
export interface BuildPageTreeOptions {
|
|
85
|
+
rootPageId?: string;
|
|
86
|
+
maxDepth?: number;
|
|
87
|
+
databases?: NotionDatabase[];
|
|
88
|
+
}
|
|
89
|
+
/** Result of building the page tree. */
|
|
90
|
+
export interface PageTreeResult {
|
|
91
|
+
tree: PageTreeNode[];
|
|
92
|
+
allPages: PageTreeNode[];
|
|
93
|
+
pagePathMap: PagePathMap;
|
|
94
|
+
databaseEntryPageIds: Set<string>;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Build a hierarchical page tree from a flat list of Notion pages.
|
|
98
|
+
*
|
|
99
|
+
* Computes kbPath for each page based on parent-child relationships,
|
|
100
|
+
* deduplicates sibling slugs, and identifies database entries.
|
|
101
|
+
*
|
|
102
|
+
* Exported so the import-orchestrator can build hierarchical paths
|
|
103
|
+
* without instantiating a full NotionSource.
|
|
104
|
+
*/
|
|
105
|
+
export declare function buildPageTree(pages: NotionPage[], options?: BuildPageTreeOptions): PageTreeResult;
|
|
73
106
|
/**
|
|
74
107
|
* Slugify a page title for use as a KB path segment.
|
|
75
108
|
* Lowercase, spaces→hyphens, strip special chars, ltree-compatible.
|
package/dist/sources/notion.js
CHANGED
|
@@ -84,12 +84,12 @@ export class NotionSource extends MigrationSource {
|
|
|
84
84
|
console.log('Discovering databases...');
|
|
85
85
|
const allNotionDatabases = await this.client.searchDatabases();
|
|
86
86
|
console.log(` Found ${allNotionDatabases.length} databases.`);
|
|
87
|
-
// 4. Build page tree
|
|
88
|
-
this.
|
|
87
|
+
// 4. Build page tree (pass databases so they become tree nodes for nesting entries)
|
|
88
|
+
this.buildPageTreeInternal(allNotionPages, allNotionDatabases);
|
|
89
89
|
// 5. Process databases — identify entries and build schemas
|
|
90
90
|
await this.processDatabases(allNotionDatabases);
|
|
91
|
-
// 6. Count and validate
|
|
92
|
-
this._documentCount = this.allPages.length;
|
|
91
|
+
// 6. Count and validate (exclude synthetic database container nodes)
|
|
92
|
+
this._documentCount = this.allPages.filter((n) => !n.isDatabaseNode).length;
|
|
93
93
|
// Add database-only entries (pages that are in databases but not in page tree)
|
|
94
94
|
for (const dbInfo of this.databases) {
|
|
95
95
|
for (const entry of dbInfo.entries) {
|
|
@@ -141,6 +141,9 @@ export class NotionSource extends MigrationSource {
|
|
|
141
141
|
// unless they also appear in the page tree (child_page)
|
|
142
142
|
if (node.isDatabaseEntry)
|
|
143
143
|
continue;
|
|
144
|
+
// Skip synthetic database container nodes — they're for nesting, not documents
|
|
145
|
+
if (node.isDatabaseNode)
|
|
146
|
+
continue;
|
|
144
147
|
const doc = await this.extractPage(node);
|
|
145
148
|
if (doc)
|
|
146
149
|
yield doc;
|
|
@@ -169,123 +172,18 @@ export class NotionSource extends MigrationSource {
|
|
|
169
172
|
await this.mediaDownloader.cleanup();
|
|
170
173
|
}
|
|
171
174
|
// ============================================
|
|
172
|
-
// Tree building
|
|
175
|
+
// Tree building (delegates to standalone function)
|
|
173
176
|
// ============================================
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
if (!childrenByParent.has(npid)) {
|
|
185
|
-
childrenByParent.set(npid, []);
|
|
186
|
-
}
|
|
187
|
-
childrenByParent.get(npid).push(page);
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
// Identify database entries
|
|
191
|
-
for (const page of pages) {
|
|
192
|
-
if (page.parent.type === 'database_id' && page.parent.database_id) {
|
|
193
|
-
this.databaseEntryPageIds.add(normalizeId(page.id));
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
// Find root pages
|
|
197
|
-
const rootPages = [];
|
|
198
|
-
if (this.config.rootPageId) {
|
|
199
|
-
// Subtree mode: start from specified page
|
|
200
|
-
const rootId = normalizeId(this.config.rootPageId);
|
|
201
|
-
const rootPage = pageById.get(rootId);
|
|
202
|
-
if (rootPage) {
|
|
203
|
-
rootPages.push(rootPage);
|
|
204
|
-
}
|
|
205
|
-
else {
|
|
206
|
-
// Root page itself wasn't in search results — search for its children
|
|
207
|
-
const children = childrenByParent.get(rootId) ?? [];
|
|
208
|
-
rootPages.push(...children);
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
else {
|
|
212
|
-
// Full workspace mode: pages with workspace parent or no parent in our set
|
|
213
|
-
for (const page of pages) {
|
|
214
|
-
if (page.parent.type === 'workspace') {
|
|
215
|
-
rootPages.push(page);
|
|
216
|
-
}
|
|
217
|
-
else {
|
|
218
|
-
const parentId = this.getParentPageId(page);
|
|
219
|
-
if (parentId && !pageById.has(normalizeId(parentId))) {
|
|
220
|
-
// Parent is not in our page set — treat as root
|
|
221
|
-
if (page.parent.type !== 'database_id') {
|
|
222
|
-
rootPages.push(page);
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
// Build tree recursively
|
|
229
|
-
const buildNode = (page, parentPath, depth, siblingSlugCounts) => {
|
|
230
|
-
if (this.config.maxDepth !== undefined && depth > this.config.maxDepth) {
|
|
231
|
-
return null;
|
|
232
|
-
}
|
|
233
|
-
const title = getPageTitle(page);
|
|
234
|
-
let slug = slugify(title);
|
|
235
|
-
// Deduplicate sibling slugs
|
|
236
|
-
const existing = siblingSlugCounts.get(slug) ?? 0;
|
|
237
|
-
siblingSlugCounts.set(slug, existing + 1);
|
|
238
|
-
if (existing > 0) {
|
|
239
|
-
slug = `${slug}-${existing + 1}`;
|
|
240
|
-
}
|
|
241
|
-
const kbPath = parentPath ? `${parentPath}/${slug}` : slug;
|
|
242
|
-
const nid = normalizeId(page.id);
|
|
243
|
-
const isDatabaseEntry = this.databaseEntryPageIds.has(nid);
|
|
244
|
-
const node = {
|
|
245
|
-
page,
|
|
246
|
-
title,
|
|
247
|
-
slug,
|
|
248
|
-
kbPath,
|
|
249
|
-
isDatabaseEntry,
|
|
250
|
-
parentDatabaseId: page.parent.type === 'database_id' ? page.parent.database_id : undefined,
|
|
251
|
-
children: [],
|
|
252
|
-
};
|
|
253
|
-
// Register in path map
|
|
254
|
-
this.pagePathMap.set(nid, kbPath);
|
|
255
|
-
// Process children
|
|
256
|
-
const childPages = childrenByParent.get(nid) ?? [];
|
|
257
|
-
const childSlugCounts = new Map();
|
|
258
|
-
for (const childPage of childPages) {
|
|
259
|
-
const childNode = buildNode(childPage, kbPath, depth + 1, childSlugCounts);
|
|
260
|
-
if (childNode) {
|
|
261
|
-
node.children.push(childNode);
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
return node;
|
|
265
|
-
};
|
|
266
|
-
// Build roots
|
|
267
|
-
const rootSlugCounts = new Map();
|
|
268
|
-
for (const rootPage of rootPages) {
|
|
269
|
-
const node = buildNode(rootPage, '', 0, rootSlugCounts);
|
|
270
|
-
if (node) {
|
|
271
|
-
this.pageTree.push(node);
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
// Flatten tree to depth-first list
|
|
275
|
-
const flatten = (nodes) => {
|
|
276
|
-
for (const node of nodes) {
|
|
277
|
-
this.allPages.push(node);
|
|
278
|
-
flatten(node.children);
|
|
279
|
-
}
|
|
280
|
-
};
|
|
281
|
-
flatten(this.pageTree);
|
|
282
|
-
}
|
|
283
|
-
getParentPageId(page) {
|
|
284
|
-
if (page.parent.type === 'page_id')
|
|
285
|
-
return page.parent.page_id ?? null;
|
|
286
|
-
if (page.parent.type === 'block_id')
|
|
287
|
-
return page.parent.block_id ?? null;
|
|
288
|
-
return null;
|
|
177
|
+
buildPageTreeInternal(pages, databases) {
|
|
178
|
+
const result = buildPageTree(pages, {
|
|
179
|
+
rootPageId: this.config.rootPageId,
|
|
180
|
+
maxDepth: this.config.maxDepth,
|
|
181
|
+
databases,
|
|
182
|
+
});
|
|
183
|
+
this.pageTree = result.tree;
|
|
184
|
+
this.allPages = result.allPages;
|
|
185
|
+
this.pagePathMap = result.pagePathMap;
|
|
186
|
+
this.databaseEntryPageIds = result.databaseEntryPageIds;
|
|
289
187
|
}
|
|
290
188
|
// ============================================
|
|
291
189
|
// Database processing
|
|
@@ -421,6 +319,183 @@ export class NotionSource extends MigrationSource {
|
|
|
421
319
|
return result;
|
|
422
320
|
}
|
|
423
321
|
}
|
|
322
|
+
/**
|
|
323
|
+
* Build a hierarchical page tree from a flat list of Notion pages.
|
|
324
|
+
*
|
|
325
|
+
* Computes kbPath for each page based on parent-child relationships,
|
|
326
|
+
* deduplicates sibling slugs, and identifies database entries.
|
|
327
|
+
*
|
|
328
|
+
* Exported so the import-orchestrator can build hierarchical paths
|
|
329
|
+
* without instantiating a full NotionSource.
|
|
330
|
+
*/
|
|
331
|
+
export function buildPageTree(pages, options) {
|
|
332
|
+
const pagePathMap = new Map();
|
|
333
|
+
const databaseEntryPageIds = new Set();
|
|
334
|
+
const tree = [];
|
|
335
|
+
const allPages = [];
|
|
336
|
+
// Build lookup maps
|
|
337
|
+
const pageById = new Map();
|
|
338
|
+
const childrenByParent = new Map();
|
|
339
|
+
const syntheticDatabaseIds = new Set();
|
|
340
|
+
for (const page of pages) {
|
|
341
|
+
const nid = normalizeId(page.id);
|
|
342
|
+
pageById.set(nid, page);
|
|
343
|
+
const parentId = getParentPageId(page);
|
|
344
|
+
if (parentId) {
|
|
345
|
+
const npid = normalizeId(parentId);
|
|
346
|
+
if (!childrenByParent.has(npid)) {
|
|
347
|
+
childrenByParent.set(npid, []);
|
|
348
|
+
}
|
|
349
|
+
childrenByParent.get(npid).push(page);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
// Inject synthetic page nodes for databases so entries nest under them
|
|
353
|
+
if (options?.databases?.length) {
|
|
354
|
+
for (const db of options.databases) {
|
|
355
|
+
const dbNid = normalizeId(db.id);
|
|
356
|
+
// Skip if a real page already has this ID
|
|
357
|
+
if (pageById.has(dbNid))
|
|
358
|
+
continue;
|
|
359
|
+
// Create synthetic NotionPage wrapper
|
|
360
|
+
const syntheticPage = {
|
|
361
|
+
id: db.id,
|
|
362
|
+
object: 'page',
|
|
363
|
+
parent: db.parent,
|
|
364
|
+
created_time: db.created_time,
|
|
365
|
+
last_edited_time: db.last_edited_time,
|
|
366
|
+
created_by: { id: '', object: 'user' },
|
|
367
|
+
last_edited_by: { id: '', object: 'user' },
|
|
368
|
+
url: '',
|
|
369
|
+
properties: {
|
|
370
|
+
title: { id: 'title', type: 'title', title: db.title },
|
|
371
|
+
},
|
|
372
|
+
};
|
|
373
|
+
pageById.set(dbNid, syntheticPage);
|
|
374
|
+
// Add to childrenByParent
|
|
375
|
+
const parentId = getParentPageId(syntheticPage);
|
|
376
|
+
if (parentId) {
|
|
377
|
+
const npid = normalizeId(parentId);
|
|
378
|
+
if (!childrenByParent.has(npid))
|
|
379
|
+
childrenByParent.set(npid, []);
|
|
380
|
+
childrenByParent.get(npid).push(syntheticPage);
|
|
381
|
+
}
|
|
382
|
+
syntheticDatabaseIds.add(dbNid);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
// Identify database entries
|
|
386
|
+
for (const page of pages) {
|
|
387
|
+
if (page.parent.type === 'database_id' && page.parent.database_id) {
|
|
388
|
+
databaseEntryPageIds.add(normalizeId(page.id));
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
// Find root pages
|
|
392
|
+
const rootPages = [];
|
|
393
|
+
if (options?.rootPageId) {
|
|
394
|
+
const rootId = normalizeId(options.rootPageId);
|
|
395
|
+
const rootPage = pageById.get(rootId);
|
|
396
|
+
if (rootPage) {
|
|
397
|
+
rootPages.push(rootPage);
|
|
398
|
+
}
|
|
399
|
+
else {
|
|
400
|
+
const children = childrenByParent.get(rootId) ?? [];
|
|
401
|
+
rootPages.push(...children);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
else {
|
|
405
|
+
for (const page of pages) {
|
|
406
|
+
if (page.parent.type === 'workspace') {
|
|
407
|
+
rootPages.push(page);
|
|
408
|
+
}
|
|
409
|
+
else {
|
|
410
|
+
const parentId = getParentPageId(page);
|
|
411
|
+
if (parentId && !pageById.has(normalizeId(parentId))) {
|
|
412
|
+
if (page.parent.type !== 'database_id') {
|
|
413
|
+
rootPages.push(page);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
// Also add synthetic database nodes whose parent is workspace or not in the tree
|
|
419
|
+
for (const dbNid of syntheticDatabaseIds) {
|
|
420
|
+
const synth = pageById.get(dbNid);
|
|
421
|
+
if (synth.parent.type === 'workspace') {
|
|
422
|
+
rootPages.push(synth);
|
|
423
|
+
}
|
|
424
|
+
else {
|
|
425
|
+
const parentId = getParentPageId(synth);
|
|
426
|
+
if (parentId && !pageById.has(normalizeId(parentId))) {
|
|
427
|
+
rootPages.push(synth);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
// Build tree recursively
|
|
433
|
+
const buildNode = (page, parentPath, depth, siblingSlugCounts) => {
|
|
434
|
+
if (options?.maxDepth !== undefined && depth > options.maxDepth) {
|
|
435
|
+
return null;
|
|
436
|
+
}
|
|
437
|
+
const title = getPageTitle(page);
|
|
438
|
+
let slug = slugify(title);
|
|
439
|
+
// Deduplicate sibling slugs
|
|
440
|
+
const existing = siblingSlugCounts.get(slug) ?? 0;
|
|
441
|
+
siblingSlugCounts.set(slug, existing + 1);
|
|
442
|
+
if (existing > 0) {
|
|
443
|
+
slug = `${slug}-${existing + 1}`;
|
|
444
|
+
}
|
|
445
|
+
const kbPath = parentPath ? `${parentPath}/${slug}` : slug;
|
|
446
|
+
const nid = normalizeId(page.id);
|
|
447
|
+
const isDatabaseEntry = databaseEntryPageIds.has(nid);
|
|
448
|
+
const node = {
|
|
449
|
+
page,
|
|
450
|
+
title,
|
|
451
|
+
slug,
|
|
452
|
+
kbPath,
|
|
453
|
+
isDatabaseEntry,
|
|
454
|
+
isDatabaseNode: syntheticDatabaseIds.has(nid) || undefined,
|
|
455
|
+
parentDatabaseId: page.parent.type === 'database_id' ? page.parent.database_id : undefined,
|
|
456
|
+
children: [],
|
|
457
|
+
};
|
|
458
|
+
// Register in path map
|
|
459
|
+
pagePathMap.set(nid, kbPath);
|
|
460
|
+
// Process children
|
|
461
|
+
const childPages = childrenByParent.get(nid) ?? [];
|
|
462
|
+
const childSlugCounts = new Map();
|
|
463
|
+
for (const childPage of childPages) {
|
|
464
|
+
const childNode = buildNode(childPage, kbPath, depth + 1, childSlugCounts);
|
|
465
|
+
if (childNode) {
|
|
466
|
+
node.children.push(childNode);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
return node;
|
|
470
|
+
};
|
|
471
|
+
// Build roots
|
|
472
|
+
const rootSlugCounts = new Map();
|
|
473
|
+
for (const rootPage of rootPages) {
|
|
474
|
+
const node = buildNode(rootPage, '', 0, rootSlugCounts);
|
|
475
|
+
if (node) {
|
|
476
|
+
tree.push(node);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
// Flatten tree to depth-first list
|
|
480
|
+
const flatten = (nodes) => {
|
|
481
|
+
for (const node of nodes) {
|
|
482
|
+
allPages.push(node);
|
|
483
|
+
flatten(node.children);
|
|
484
|
+
}
|
|
485
|
+
};
|
|
486
|
+
flatten(tree);
|
|
487
|
+
return { tree, allPages, pagePathMap, databaseEntryPageIds };
|
|
488
|
+
}
|
|
489
|
+
/** Extract the parent page ID from a Notion page's parent field. */
|
|
490
|
+
function getParentPageId(page) {
|
|
491
|
+
if (page.parent.type === 'page_id')
|
|
492
|
+
return page.parent.page_id ?? null;
|
|
493
|
+
if (page.parent.type === 'database_id')
|
|
494
|
+
return page.parent.database_id ?? null;
|
|
495
|
+
if (page.parent.type === 'block_id')
|
|
496
|
+
return page.parent.block_id ?? null;
|
|
497
|
+
return null;
|
|
498
|
+
}
|
|
424
499
|
// ============================================
|
|
425
500
|
// Path helpers
|
|
426
501
|
// ============================================
|
package/package.json
CHANGED