@moxn/kb-migrate 0.4.20 → 0.4.22

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 CHANGED
@@ -72,6 +72,7 @@ export declare class MoxnClient {
72
72
  path: string;
73
73
  description?: string;
74
74
  color?: string;
75
+ displayName?: string;
75
76
  }): Promise<{
76
77
  id: string;
77
78
  path: string;
@@ -81,6 +82,20 @@ export declare class MoxnClient {
81
82
  * Assign a tag to a document.
82
83
  */
83
84
  assignTag(documentId: string, tagId: string, branchId: string): Promise<void>;
85
+ /**
86
+ * Batch-create references from a document's sections to other documents.
87
+ * Returns { created, skipped, errors }.
88
+ */
89
+ createReferences(documentId: string, references: Array<{
90
+ sourceSectionId: string;
91
+ branchId: string;
92
+ targetDocumentId: string;
93
+ displayTitle?: string | null;
94
+ }>): Promise<{
95
+ created: number;
96
+ skipped: number;
97
+ errors: number;
98
+ }>;
84
99
  /**
85
100
  * List all KB databases for the tenant.
86
101
  */
package/dist/client.js CHANGED
@@ -51,6 +51,9 @@ export class MoxnClient {
51
51
  documentId: createResult.id,
52
52
  branchId: createResult.branchId,
53
53
  sectionsCount: createResult.sections.length,
54
+ sectionIds: createResult.sections.map((s) => s.id),
55
+ references: doc.references,
56
+ sourcePageId: doc.metadata?.notionPageId,
54
57
  duration: Date.now() - startTime,
55
58
  };
56
59
  }
@@ -82,6 +85,9 @@ export class MoxnClient {
82
85
  documentId: updateResult.id,
83
86
  branchId: updateResult.branchId,
84
87
  sectionsCount: updateResult.sections.length,
88
+ sectionIds: updateResult.sections.map((s) => s.id),
89
+ references: doc.references,
90
+ sourcePageId: doc.metadata?.notionPageId,
85
91
  duration: Date.now() - startTime,
86
92
  };
87
93
  }
@@ -414,6 +420,25 @@ export class MoxnClient {
414
420
  throw new Error(body.error || `Failed to assign tag: ${response.status}`);
415
421
  }
416
422
  }
423
+ /**
424
+ * Batch-create references from a document's sections to other documents.
425
+ * Returns { created, skipped, errors }.
426
+ */
427
+ async createReferences(documentId, references) {
428
+ const response = await fetch(`${this.apiUrl}/api/v1/kb/documents/${documentId}/references`, {
429
+ method: 'POST',
430
+ headers: {
431
+ 'Content-Type': 'application/json',
432
+ 'x-api-key': this.apiKey,
433
+ },
434
+ body: JSON.stringify({ references }),
435
+ });
436
+ if (!response.ok) {
437
+ const body = await response.json().catch(() => ({}));
438
+ throw new Error(body.error || `Failed to create references: ${response.status}`);
439
+ }
440
+ return response.json();
441
+ }
417
442
  /**
418
443
  * List all KB databases for the tenant.
419
444
  */
package/dist/index.js CHANGED
@@ -140,6 +140,85 @@ function printNotionExportSummary(log) {
140
140
  console.log('\n(Dry run - no changes made)');
141
141
  }
142
142
  }
143
+ /**
144
+ * Resolve cross-references after migration.
145
+ *
146
+ * Builds a notionId → { documentId, branchId, sectionIds } mapping from results,
147
+ * then creates KB Reference objects for each extracted cross-reference.
148
+ */
149
+ async function resolveReferences(client, results) {
150
+ // Build notionId → document info mapping
151
+ const notionIdMap = new Map();
152
+ for (const result of results) {
153
+ if (result.sourcePageId &&
154
+ result.documentId &&
155
+ result.branchId &&
156
+ result.sectionIds &&
157
+ (result.status === 'created' || result.status === 'updated')) {
158
+ notionIdMap.set(result.sourcePageId, {
159
+ documentId: result.documentId,
160
+ branchId: result.branchId,
161
+ sectionIds: result.sectionIds,
162
+ });
163
+ }
164
+ }
165
+ let totalCreated = 0;
166
+ let totalSkipped = 0;
167
+ let totalErrors = 0;
168
+ let unresolvedTargets = 0;
169
+ let invalidSectionIndexes = 0;
170
+ for (const result of results) {
171
+ if (!result.references?.length ||
172
+ !result.documentId ||
173
+ !result.branchId ||
174
+ !result.sectionIds) {
175
+ continue;
176
+ }
177
+ if (result.status !== 'created' && result.status !== 'updated') {
178
+ continue;
179
+ }
180
+ const refs = [];
181
+ for (const ref of result.references) {
182
+ // Resolve target Notion ID → Moxn document ID
183
+ const target = notionIdMap.get(ref.targetNotionId);
184
+ if (!target) {
185
+ unresolvedTargets++;
186
+ continue;
187
+ }
188
+ // Map sectionIndex → sourceSectionId
189
+ const sourceSectionId = result.sectionIds[ref.sectionIndex];
190
+ if (!sourceSectionId) {
191
+ invalidSectionIndexes++;
192
+ continue;
193
+ }
194
+ refs.push({
195
+ sourceSectionId,
196
+ branchId: result.branchId,
197
+ targetDocumentId: target.documentId,
198
+ displayTitle: ref.displayText || null,
199
+ });
200
+ }
201
+ if (refs.length === 0)
202
+ continue;
203
+ try {
204
+ const { created, skipped, errors } = await client.createReferences(result.documentId, refs);
205
+ totalCreated += created;
206
+ totalSkipped += skipped;
207
+ totalErrors += errors;
208
+ }
209
+ catch (error) {
210
+ console.error(` Failed to create references for ${result.documentPath}: ${error instanceof Error ? error.message : error}`);
211
+ totalErrors += refs.length;
212
+ }
213
+ }
214
+ if (unresolvedTargets > 0) {
215
+ console.log(` ${unresolvedTargets} reference(s) skipped: target page not in import scope`);
216
+ }
217
+ if (invalidSectionIndexes > 0) {
218
+ console.log(` ${invalidSectionIndexes} reference(s) skipped: section index out of bounds`);
219
+ }
220
+ return { created: totalCreated, skipped: totalSkipped, errors: totalErrors };
221
+ }
143
222
  const program = new Command();
144
223
  program
145
224
  .name('moxn-kb-migrate')
@@ -354,7 +433,17 @@ program
354
433
  }
355
434
  // Step 3: Run page migration (validate is idempotent, extract uses databaseIdMap)
356
435
  const log = await runMigration(source, migrationOptions);
357
- // Step 4: Finalize databases — create columns, link entries, assign tags.
436
+ // Step 4: Resolve cross-references (between page migration and database finalization)
437
+ if (!opts.dryRun) {
438
+ const refsWithData = log.results.filter((r) => r.references?.length && r.sectionIds?.length);
439
+ if (refsWithData.length > 0) {
440
+ console.log(`\nResolving cross-references from ${refsWithData.length} document(s)...`);
441
+ const refClient = new MoxnClient(migrationOptions);
442
+ const refResult = await resolveReferences(refClient, log.results);
443
+ console.log(` Created ${refResult.created} reference(s) (${refResult.skipped} skipped, ${refResult.errors} errors)`);
444
+ }
445
+ }
446
+ // Step 5: Finalize databases — create columns, link entries, assign tags.
358
447
  // Database records already exist from pre-create; this step adds schema + data.
359
448
  if (!opts.dryRun && dbImports.length > 0) {
360
449
  console.log(`\nFinalizing ${dbImports.length} database(s)...`);
@@ -537,6 +626,7 @@ async function importNotionDatabase(client, dbImport, log, options, preCreatedDb
537
626
  const tag = await client.createTag({
538
627
  path: tagPath,
539
628
  color: notionColorToHex(option.color),
629
+ displayName: option.name,
540
630
  });
541
631
  optionTagMap.set(option.name, tag.id);
542
632
  tagIds.push(tag.id);
@@ -10,6 +10,7 @@
10
10
  import type { ExtractedDocument } from '../types.js';
11
11
  import { MigrationSource, type SourceConfig } from './base.js';
12
12
  import type { NotionPage } 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,15 @@ 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
+ parentDatabaseId?: string;
30
+ }
21
31
  /** Info yielded to the migration runner for database import (post-document pass). */
22
32
  export interface NotionDatabaseImportInfo {
23
33
  notionDatabaseId: string;
@@ -62,14 +72,35 @@ export declare class NotionSource extends MigrationSource<NotionSourceConfig> {
62
72
  extract(): AsyncGenerator<ExtractedDocument, void, unknown>;
63
73
  /** Clean up temp files after migration completes. */
64
74
  cleanup(): Promise<void>;
65
- private buildPageTree;
66
- private getParentPageId;
75
+ private buildPageTreeInternal;
67
76
  private processDatabases;
68
77
  private getDatabaseParentId;
69
78
  private extractPage;
70
79
  private extractDatabaseEntry;
71
80
  private downloadSectionMedia;
72
81
  }
82
+ /** Options for building the page tree. */
83
+ export interface BuildPageTreeOptions {
84
+ rootPageId?: string;
85
+ maxDepth?: number;
86
+ }
87
+ /** Result of building the page tree. */
88
+ export interface PageTreeResult {
89
+ tree: PageTreeNode[];
90
+ allPages: PageTreeNode[];
91
+ pagePathMap: PagePathMap;
92
+ databaseEntryPageIds: Set<string>;
93
+ }
94
+ /**
95
+ * Build a hierarchical page tree from a flat list of Notion pages.
96
+ *
97
+ * Computes kbPath for each page based on parent-child relationships,
98
+ * deduplicates sibling slugs, and identifies database entries.
99
+ *
100
+ * Exported so the import-orchestrator can build hierarchical paths
101
+ * without instantiating a full NotionSource.
102
+ */
103
+ export declare function buildPageTree(pages: NotionPage[], options?: BuildPageTreeOptions): PageTreeResult;
73
104
  /**
74
105
  * Slugify a page title for use as a KB path segment.
75
106
  * Lowercase, spaces→hyphens, strip special chars, ltree-compatible.
@@ -85,7 +85,7 @@ export class NotionSource extends MigrationSource {
85
85
  const allNotionDatabases = await this.client.searchDatabases();
86
86
  console.log(` Found ${allNotionDatabases.length} databases.`);
87
87
  // 4. Build page tree
88
- this.buildPageTree(allNotionPages);
88
+ this.buildPageTreeInternal(allNotionPages);
89
89
  // 5. Process databases — identify entries and build schemas
90
90
  await this.processDatabases(allNotionDatabases);
91
91
  // 6. Count and validate
@@ -169,123 +169,17 @@ export class NotionSource extends MigrationSource {
169
169
  await this.mediaDownloader.cleanup();
170
170
  }
171
171
  // ============================================
172
- // Tree building
172
+ // Tree building (delegates to standalone function)
173
173
  // ============================================
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;
174
+ buildPageTreeInternal(pages) {
175
+ const result = buildPageTree(pages, {
176
+ rootPageId: this.config.rootPageId,
177
+ maxDepth: this.config.maxDepth,
178
+ });
179
+ this.pageTree = result.tree;
180
+ this.allPages = result.allPages;
181
+ this.pagePathMap = result.pagePathMap;
182
+ this.databaseEntryPageIds = result.databaseEntryPageIds;
289
183
  }
290
184
  // ============================================
291
185
  // Database processing
@@ -421,6 +315,133 @@ export class NotionSource extends MigrationSource {
421
315
  return result;
422
316
  }
423
317
  }
318
+ /**
319
+ * Build a hierarchical page tree from a flat list of Notion pages.
320
+ *
321
+ * Computes kbPath for each page based on parent-child relationships,
322
+ * deduplicates sibling slugs, and identifies database entries.
323
+ *
324
+ * Exported so the import-orchestrator can build hierarchical paths
325
+ * without instantiating a full NotionSource.
326
+ */
327
+ export function buildPageTree(pages, options) {
328
+ const pagePathMap = new Map();
329
+ const databaseEntryPageIds = new Set();
330
+ const tree = [];
331
+ const allPages = [];
332
+ // Build lookup maps
333
+ const pageById = new Map();
334
+ const childrenByParent = new Map();
335
+ for (const page of pages) {
336
+ const nid = normalizeId(page.id);
337
+ pageById.set(nid, page);
338
+ const parentId = getParentPageId(page);
339
+ if (parentId) {
340
+ const npid = normalizeId(parentId);
341
+ if (!childrenByParent.has(npid)) {
342
+ childrenByParent.set(npid, []);
343
+ }
344
+ childrenByParent.get(npid).push(page);
345
+ }
346
+ }
347
+ // Identify database entries
348
+ for (const page of pages) {
349
+ if (page.parent.type === 'database_id' && page.parent.database_id) {
350
+ databaseEntryPageIds.add(normalizeId(page.id));
351
+ }
352
+ }
353
+ // Find root pages
354
+ const rootPages = [];
355
+ if (options?.rootPageId) {
356
+ const rootId = normalizeId(options.rootPageId);
357
+ const rootPage = pageById.get(rootId);
358
+ if (rootPage) {
359
+ rootPages.push(rootPage);
360
+ }
361
+ else {
362
+ const children = childrenByParent.get(rootId) ?? [];
363
+ rootPages.push(...children);
364
+ }
365
+ }
366
+ else {
367
+ for (const page of pages) {
368
+ if (page.parent.type === 'workspace') {
369
+ rootPages.push(page);
370
+ }
371
+ else {
372
+ const parentId = getParentPageId(page);
373
+ if (parentId && !pageById.has(normalizeId(parentId))) {
374
+ if (page.parent.type !== 'database_id') {
375
+ rootPages.push(page);
376
+ }
377
+ }
378
+ }
379
+ }
380
+ }
381
+ // Build tree recursively
382
+ const buildNode = (page, parentPath, depth, siblingSlugCounts) => {
383
+ if (options?.maxDepth !== undefined && depth > options.maxDepth) {
384
+ return null;
385
+ }
386
+ const title = getPageTitle(page);
387
+ let slug = slugify(title);
388
+ // Deduplicate sibling slugs
389
+ const existing = siblingSlugCounts.get(slug) ?? 0;
390
+ siblingSlugCounts.set(slug, existing + 1);
391
+ if (existing > 0) {
392
+ slug = `${slug}-${existing + 1}`;
393
+ }
394
+ const kbPath = parentPath ? `${parentPath}/${slug}` : slug;
395
+ const nid = normalizeId(page.id);
396
+ const isDatabaseEntry = databaseEntryPageIds.has(nid);
397
+ const node = {
398
+ page,
399
+ title,
400
+ slug,
401
+ kbPath,
402
+ isDatabaseEntry,
403
+ parentDatabaseId: page.parent.type === 'database_id' ? page.parent.database_id : undefined,
404
+ children: [],
405
+ };
406
+ // Register in path map
407
+ pagePathMap.set(nid, kbPath);
408
+ // Process children
409
+ const childPages = childrenByParent.get(nid) ?? [];
410
+ const childSlugCounts = new Map();
411
+ for (const childPage of childPages) {
412
+ const childNode = buildNode(childPage, kbPath, depth + 1, childSlugCounts);
413
+ if (childNode) {
414
+ node.children.push(childNode);
415
+ }
416
+ }
417
+ return node;
418
+ };
419
+ // Build roots
420
+ const rootSlugCounts = new Map();
421
+ for (const rootPage of rootPages) {
422
+ const node = buildNode(rootPage, '', 0, rootSlugCounts);
423
+ if (node) {
424
+ tree.push(node);
425
+ }
426
+ }
427
+ // Flatten tree to depth-first list
428
+ const flatten = (nodes) => {
429
+ for (const node of nodes) {
430
+ allPages.push(node);
431
+ flatten(node.children);
432
+ }
433
+ };
434
+ flatten(tree);
435
+ return { tree, allPages, pagePathMap, databaseEntryPageIds };
436
+ }
437
+ /** Extract the parent page ID from a Notion page's parent field. */
438
+ function getParentPageId(page) {
439
+ if (page.parent.type === 'page_id')
440
+ return page.parent.page_id ?? null;
441
+ if (page.parent.type === 'block_id')
442
+ return page.parent.block_id ?? null;
443
+ return null;
444
+ }
424
445
  // ============================================
425
446
  // Path helpers
426
447
  // ============================================
@@ -292,7 +292,10 @@ async function createDatabaseEntries(ctx, dataSourceId, resolved) {
292
292
  }
293
293
  else {
294
294
  // Scalar columns (number, date, text, url, email)
295
- if (propValue.textValue == null && propValue.numberValue == null && propValue.dateValue == null && propValue.booleanValue == null)
295
+ if (propValue.textValue == null &&
296
+ propValue.numberValue == null &&
297
+ propValue.dateValue == null &&
298
+ propValue.booleanValue == null)
296
299
  continue;
297
300
  switch (propValue.columnType) {
298
301
  case 'number':
package/dist/types.d.ts CHANGED
@@ -139,6 +139,12 @@ export interface MigrationResult {
139
139
  documentId?: string;
140
140
  branchId?: string;
141
141
  sectionsCount?: number;
142
+ /** Ordered section IDs from create/update response (for cross-reference resolution) */
143
+ sectionIds?: string[];
144
+ /** Cross-references extracted from source (for post-migration resolution) */
145
+ references?: ExtractedReference[];
146
+ /** Source page ID (e.g. Notion page ID) for building cross-ref mappings */
147
+ sourcePageId?: string;
142
148
  error?: string;
143
149
  duration?: number;
144
150
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moxn/kb-migrate",
3
- "version": "0.4.20",
3
+ "version": "0.4.22",
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",