@moxn/kb-migrate 0.4.24 → 0.4.25

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
@@ -53,7 +53,7 @@ export declare class MoxnClient {
53
53
  */
54
54
  addDatabaseColumn(databaseId: string, input: {
55
55
  name: string;
56
- type: 'select' | 'multi_select' | 'status' | 'checkbox' | 'number' | 'date' | 'text' | 'url' | 'email';
56
+ type: 'select' | 'multi_select' | 'status' | 'checkbox' | 'number' | 'date' | 'text' | 'url' | 'email' | 'page_ref';
57
57
  optionTagIds?: string[];
58
58
  newOptionParentPath?: string;
59
59
  config?: Record<string, unknown>;
package/dist/index.js CHANGED
@@ -63,7 +63,9 @@ async function runMigration(source, options) {
63
63
  // Circuit breaker: abort after too many consecutive failures
64
64
  if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
65
65
  console.error(`\nAborting: ${MAX_CONSECUTIVE_FAILURES} consecutive failures. ` +
66
- 'Last error: ' + (result.error || 'unknown') + '\n' +
66
+ 'Last error: ' +
67
+ (result.error || 'unknown') +
68
+ '\n' +
67
69
  'Fix the underlying issue and retry. Remaining documents will be skipped.');
68
70
  break;
69
71
  }
@@ -688,6 +690,8 @@ async function importNotionDatabase(client, dbImport, log, options, preCreatedDb
688
690
  // 3. Link entries and assign tags
689
691
  // Build a map of KB path → { documentId, branchId } from migration results
690
692
  const docByPath = new Map();
693
+ // Build a map of Notion page ID → KB document ID for page_ref resolution
694
+ const notionIdToDocId = new Map();
691
695
  for (const result of log.results) {
692
696
  if (result.documentId && result.branchId) {
693
697
  docByPath.set(result.documentPath, {
@@ -695,6 +699,9 @@ async function importNotionDatabase(client, dbImport, log, options, preCreatedDb
695
699
  branchId: result.branchId,
696
700
  });
697
701
  }
702
+ if (result.sourcePageId && result.documentId) {
703
+ notionIdToDocId.set(result.sourcePageId, result.documentId);
704
+ }
698
705
  }
699
706
  let linkedCount = 0;
700
707
  for (const entry of entries) {
@@ -749,6 +756,19 @@ async function importNotionDatabase(client, dbImport, log, options, preCreatedDb
749
756
  case 'email':
750
757
  value = scalarVal.textValue ?? null;
751
758
  break;
759
+ case 'page_ref': {
760
+ // Resolve notion:XXX markers to KB document IDs
761
+ const refs = JSON.parse(scalarVal.textValue ?? '[]');
762
+ const resolvedRefs = refs.map((ref) => {
763
+ if (ref.startsWith('notion:')) {
764
+ const notionId = ref.substring(7);
765
+ return notionIdToDocId.get(notionId) ?? ref;
766
+ }
767
+ return ref;
768
+ });
769
+ value = JSON.stringify(resolvedRefs);
770
+ break;
771
+ }
752
772
  }
753
773
  if (value != null) {
754
774
  try {
@@ -30,8 +30,8 @@ export interface MappedColumn {
30
30
  export interface ScalarColumn {
31
31
  notionPropertyId: string;
32
32
  notionPropertyName: string;
33
- notionType: 'number' | 'date' | 'url' | 'email' | 'rich_text' | 'checkbox';
34
- moxnType: 'number' | 'date' | 'url' | 'email' | 'text';
33
+ notionType: 'number' | 'date' | 'url' | 'email' | 'rich_text' | 'checkbox' | 'relation';
34
+ moxnType: 'number' | 'date' | 'url' | 'email' | 'text' | 'page_ref';
35
35
  }
36
36
  /** Parsed Notion column that will be rendered as text in a Properties section. */
37
37
  export interface UnmappedColumn {
@@ -50,7 +50,7 @@ export interface ParsedDatabaseSchema {
50
50
  /** Scalar value for a single database entry property. */
51
51
  export interface ScalarValue {
52
52
  columnName: string;
53
- moxnType: 'number' | 'date' | 'url' | 'email' | 'text';
53
+ moxnType: 'number' | 'date' | 'url' | 'email' | 'text' | 'page_ref';
54
54
  textValue?: string | null;
55
55
  numberValue?: number | null;
56
56
  dateValue?: string | null;
@@ -123,6 +123,14 @@ export function parseDatabaseSchema(db) {
123
123
  moxnType: 'text',
124
124
  });
125
125
  }
126
+ else if (prop.type === 'relation') {
127
+ scalarColumns.push({
128
+ notionPropertyId: prop.id,
129
+ notionPropertyName: propName,
130
+ notionType: 'relation',
131
+ moxnType: 'page_ref',
132
+ });
133
+ }
126
134
  else {
127
135
  // Warn on property types that have no meaningful static value
128
136
  if (prop.type === 'button' || prop.type === 'ai_text') {
@@ -175,9 +183,24 @@ export function parseEntryValues(page, schema) {
175
183
  // Check if this is a scalar column
176
184
  const scalar = scalarByName.get(propName);
177
185
  if (scalar) {
178
- const extracted = extractScalarValue(propValue, scalar);
179
- if (extracted) {
180
- scalarValues.set(propName, extracted);
186
+ // page_ref columns extract relation page IDs (handled differently from standard scalars)
187
+ if (scalar.moxnType === 'page_ref') {
188
+ const refs = (propValue.relation ?? [])
189
+ .map((r) => normalizeId(r.id))
190
+ .filter(Boolean);
191
+ if (refs.length > 0) {
192
+ scalarValues.set(propName, {
193
+ columnName: propName,
194
+ moxnType: 'page_ref',
195
+ textValue: JSON.stringify(refs.map((id) => `notion:${id}`)),
196
+ });
197
+ }
198
+ }
199
+ else {
200
+ const extracted = extractScalarValue(propValue, scalar);
201
+ if (extracted) {
202
+ scalarValues.set(propName, extracted);
203
+ }
181
204
  }
182
205
  continue;
183
206
  }
@@ -10,6 +10,7 @@
10
10
  import type { SectionInput } from '../types.js';
11
11
  export interface NotionIdMapping {
12
12
  notionIdToKbPath: Map<string, string>;
13
+ notionIdToTitle?: Map<string, string>;
13
14
  }
14
15
  export interface ExtractedReference {
15
16
  sectionIndex: number;
@@ -125,17 +125,19 @@ export function resolveNotionReferences(sections, mapping) {
125
125
  text = text.replace(RELATION_REF_RE, (_match, rawId) => {
126
126
  const nid = normalizeId(rawId);
127
127
  const kbPath = mapping.notionIdToKbPath.get(nid);
128
+ const title = mapping.notionIdToTitle?.get(nid);
128
129
  if (kbPath) {
130
+ const displayText = title || kbPath;
129
131
  references.push({
130
132
  sectionIndex,
131
133
  targetNotionId: nid,
132
134
  targetKbPath: kbPath,
133
- displayText: kbPath,
135
+ displayText,
134
136
  });
135
- return `[${kbPath}](${kbPath})`;
137
+ return `[${displayText}](${kbPath})`;
136
138
  }
137
- // Unresolved — keep the ID
138
- return nid;
139
+ // Unresolved — show title if available, otherwise keep the ID
140
+ return title || nid;
139
141
  });
140
142
  return { blockType: 'text', text };
141
143
  });
@@ -151,9 +153,10 @@ export function resolveRelationIds(value, mapping) {
151
153
  return value.replace(RELATION_REF_RE, (_match, rawId) => {
152
154
  const nid = normalizeId(rawId);
153
155
  const kbPath = mapping.notionIdToKbPath.get(nid);
156
+ const title = mapping.notionIdToTitle?.get(nid);
154
157
  if (kbPath) {
155
- return `[${kbPath}](${kbPath})`;
158
+ return `[${title || kbPath}](${kbPath})`;
156
159
  }
157
- return nid;
160
+ return title || nid;
158
161
  });
159
162
  }
@@ -48,6 +48,7 @@ export declare class NotionSource extends MigrationSource<NotionSourceConfig> {
48
48
  private pageTree;
49
49
  private allPages;
50
50
  private pagePathMap;
51
+ private pageTitleMap;
51
52
  private databases;
52
53
  private databaseEntryPageIds;
53
54
  private _documentCount;
@@ -96,6 +97,7 @@ export interface PageTreeResult {
96
97
  tree: PageTreeNode[];
97
98
  allPages: PageTreeNode[];
98
99
  pagePathMap: PagePathMap;
100
+ pageTitleMap: Map<string, string>;
99
101
  databaseEntryPageIds: Set<string>;
100
102
  }
101
103
  /**
@@ -24,6 +24,7 @@ export class NotionSource extends MigrationSource {
24
24
  pageTree = [];
25
25
  allPages = []; // flat list, depth-first order
26
26
  pagePathMap = new Map();
27
+ pageTitleMap = new Map();
27
28
  databases = [];
28
29
  databaseEntryPageIds = new Set();
29
30
  _documentCount = 0;
@@ -240,6 +241,7 @@ export class NotionSource extends MigrationSource {
240
241
  this.pageTree = result.tree;
241
242
  this.allPages = result.allPages;
242
243
  this.pagePathMap = result.pagePathMap;
244
+ this.pageTitleMap = result.pageTitleMap;
243
245
  this.databaseEntryPageIds = result.databaseEntryPageIds;
244
246
  }
245
247
  // ============================================
@@ -291,7 +293,7 @@ export class NotionSource extends MigrationSource {
291
293
  // Download Notion-hosted media files
292
294
  sections = await this.downloadSectionMedia(sections);
293
295
  // Resolve cross-references
294
- const { sections: resolvedSections, references } = resolveNotionReferences(sections, { notionIdToKbPath: this.pagePathMap });
296
+ const { sections: resolvedSections, references } = resolveNotionReferences(sections, { notionIdToKbPath: this.pagePathMap, notionIdToTitle: this.pageTitleMap });
295
297
  sections = resolvedSections;
296
298
  if (sections.length === 0) {
297
299
  this._skippedNoContent.push(node.title);
@@ -339,7 +341,7 @@ export class NotionSource extends MigrationSource {
339
341
  // Download media
340
342
  let processedSections = await this.downloadSectionMedia(sections);
341
343
  // Resolve cross-references
342
- const { sections: resolvedSections, references } = resolveNotionReferences(processedSections, { notionIdToKbPath: this.pagePathMap });
344
+ const { sections: resolvedSections, references } = resolveNotionReferences(processedSections, { notionIdToKbPath: this.pagePathMap, notionIdToTitle: this.pageTitleMap });
343
345
  processedSections = resolvedSections;
344
346
  const nid = normalizeId(entry.id);
345
347
  const kbPath = this.pagePathMap.get(nid) ?? slug;
@@ -392,6 +394,7 @@ export class NotionSource extends MigrationSource {
392
394
  */
393
395
  export function buildPageTree(pages, options) {
394
396
  const pagePathMap = new Map();
397
+ const pageTitleMap = new Map();
395
398
  const databaseEntryPageIds = new Set();
396
399
  const tree = [];
397
400
  const allPages = [];
@@ -493,7 +496,11 @@ export function buildPageTree(pages, options) {
493
496
  }
494
497
  // Build tree recursively
495
498
  const buildNode = (page, parentPath, depth, siblingSlugCounts) => {
496
- if (options?.maxDepth !== undefined && depth > options.maxDepth) {
499
+ const nid = normalizeId(page.id);
500
+ const isDatabaseSubtree = syntheticDatabaseIds.has(nid) || databaseEntryPageIds.has(nid);
501
+ if (options?.maxDepth !== undefined &&
502
+ depth > options.maxDepth &&
503
+ !isDatabaseSubtree) {
497
504
  return null;
498
505
  }
499
506
  const title = getPageTitle(page);
@@ -505,7 +512,6 @@ export function buildPageTree(pages, options) {
505
512
  slug = `${slug}-${existing + 1}`;
506
513
  }
507
514
  const kbPath = parentPath ? `${parentPath}/${slug}` : slug;
508
- const nid = normalizeId(page.id);
509
515
  const isDatabaseEntry = databaseEntryPageIds.has(nid);
510
516
  const node = {
511
517
  page,
@@ -517,8 +523,9 @@ export function buildPageTree(pages, options) {
517
523
  parentDatabaseId: page.parent.type === 'database_id' ? page.parent.database_id : undefined,
518
524
  children: [],
519
525
  };
520
- // Register in path map
526
+ // Register in path map and title map
521
527
  pagePathMap.set(nid, kbPath);
528
+ pageTitleMap.set(nid, title);
522
529
  // Process children
523
530
  const childPages = childrenByParent.get(nid) ?? [];
524
531
  const childSlugCounts = new Map();
@@ -546,7 +553,7 @@ export function buildPageTree(pages, options) {
546
553
  }
547
554
  };
548
555
  flatten(tree);
549
- return { tree, allPages, pagePathMap, databaseEntryPageIds };
556
+ return { tree, allPages, pagePathMap, pageTitleMap, databaseEntryPageIds };
550
557
  }
551
558
  /** Extract the parent page ID from a Notion page's parent field. */
552
559
  function getParentPageId(page) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moxn/kb-migrate",
3
- "version": "0.4.24",
3
+ "version": "0.4.25",
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",