@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.
@@ -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 buildPageTree;
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.
@@ -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.buildPageTree(allNotionPages);
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
- buildPageTree(pages) {
175
- // Build lookup maps
176
- const pageById = new Map();
177
- const childrenByParent = new Map();
178
- for (const page of pages) {
179
- const nid = normalizeId(page.id);
180
- pageById.set(nid, page);
181
- const parentId = this.getParentPageId(page);
182
- if (parentId) {
183
- const npid = normalizeId(parentId);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moxn/kb-migrate",
3
- "version": "0.4.21",
3
+ "version": "0.4.23",
4
4
  "description": "Migration tool for importing documents into Moxn Knowledge Base from local files, Notion, Google Docs, and more",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",