@moxn/kb-migrate 0.4.19 → 0.4.21

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,9 +53,10 @@ export declare class MoxnClient {
53
53
  */
54
54
  addDatabaseColumn(databaseId: string, input: {
55
55
  name: string;
56
- type: 'select' | 'multi_select';
56
+ type: 'select' | 'multi_select' | 'status' | 'checkbox' | 'number' | 'date' | 'text' | 'url' | 'email';
57
57
  optionTagIds?: string[];
58
58
  newOptionParentPath?: string;
59
+ config?: Record<string, unknown>;
59
60
  }): Promise<{
60
61
  id: string;
61
62
  name: string;
@@ -71,6 +72,7 @@ export declare class MoxnClient {
71
72
  path: string;
72
73
  description?: string;
73
74
  color?: string;
75
+ displayName?: string;
74
76
  }): Promise<{
75
77
  id: string;
76
78
  path: string;
@@ -80,6 +82,20 @@ export declare class MoxnClient {
80
82
  * Assign a tag to a document.
81
83
  */
82
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
+ }>;
83
99
  /**
84
100
  * List all KB databases for the tenant.
85
101
  */
@@ -97,9 +113,10 @@ export declare class MoxnClient {
97
113
  columns: Array<{
98
114
  id: string;
99
115
  name: string;
100
- type: 'select' | 'multi_select';
116
+ type: string;
101
117
  position: number;
102
118
  newOptionParentPath: string | null;
119
+ config?: Record<string, unknown> | null;
103
120
  options: Array<{
104
121
  tagId: string;
105
122
  tagName: string;
@@ -114,13 +131,21 @@ export declare class MoxnClient {
114
131
  properties: Record<string, {
115
132
  columnId: string;
116
133
  columnName: string;
117
- columnType: 'select' | 'multi_select';
134
+ columnType: string;
118
135
  values: Array<{
119
136
  tagId: string;
120
137
  tagName: string;
121
138
  tagPath: string;
122
139
  tagColor: string | null;
123
140
  }>;
141
+ } | {
142
+ columnId: string;
143
+ columnName: string;
144
+ columnType: string;
145
+ textValue: string | null;
146
+ numberValue: number | null;
147
+ dateValue: string | null;
148
+ booleanValue: boolean | null;
124
149
  }>;
125
150
  }>;
126
151
  }>;
@@ -147,5 +172,9 @@ export declare class MoxnClient {
147
172
  id: string;
148
173
  };
149
174
  }>;
175
+ /**
176
+ * Set a scalar property value on a document in a database.
177
+ */
178
+ setPropertyValue(databaseId: string, documentId: string, columnName: string, value: string | number | boolean | null): Promise<void>;
150
179
  private isConflictError;
151
180
  }
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
  */
@@ -470,6 +495,23 @@ export class MoxnClient {
470
495
  }
471
496
  return response.json();
472
497
  }
498
+ /**
499
+ * Set a scalar property value on a document in a database.
500
+ */
501
+ async setPropertyValue(databaseId, documentId, columnName, value) {
502
+ const response = await fetch(`${this.apiUrl}/api/v1/kb/databases/${databaseId}/properties`, {
503
+ method: 'POST',
504
+ headers: {
505
+ 'Content-Type': 'application/json',
506
+ 'x-api-key': this.apiKey,
507
+ },
508
+ body: JSON.stringify({ documentId, columnName, value }),
509
+ });
510
+ if (!response.ok) {
511
+ const body = await response.json().catch(() => ({}));
512
+ throw new Error(body.error || `Failed to set property value: ${response.status}`);
513
+ }
514
+ }
473
515
  isConflictError(error) {
474
516
  return (error instanceof Error &&
475
517
  'documentId' in error &&
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)...`);
@@ -370,6 +459,9 @@ program
370
459
  for (const col of dbImport.schema.mappedColumns) {
371
460
  allPropertyTypes.add(col.notionType);
372
461
  }
462
+ for (const col of dbImport.schema.scalarColumns) {
463
+ allPropertyTypes.add(col.notionType);
464
+ }
373
465
  for (const col of dbImport.schema.unmappedColumns) {
374
466
  allPropertyTypes.add(col.notionType);
375
467
  }
@@ -534,6 +626,7 @@ async function importNotionDatabase(client, dbImport, log, options, preCreatedDb
534
626
  const tag = await client.createTag({
535
627
  path: tagPath,
536
628
  color: notionColorToHex(option.color),
629
+ displayName: option.name,
537
630
  });
538
631
  optionTagMap.set(option.name, tag.id);
539
632
  tagIds.push(tag.id);
@@ -560,6 +653,24 @@ async function importNotionDatabase(client, dbImport, log, options, preCreatedDb
560
653
  console.warn(` Warning: Failed to create column "${col.notionPropertyName}": ${error instanceof Error ? error.message : error}`);
561
654
  }
562
655
  }
656
+ // 2b. Create scalar columns (number, date, text, url, email)
657
+ const scalarColumnMap = new Map();
658
+ for (const col of schema.scalarColumns) {
659
+ try {
660
+ const column = await client.addDatabaseColumn(dbId, {
661
+ name: col.notionPropertyName,
662
+ type: col.moxnType,
663
+ });
664
+ scalarColumnMap.set(col.notionPropertyName, {
665
+ columnId: column.id,
666
+ moxnType: col.moxnType,
667
+ });
668
+ console.log(` Column "${col.notionPropertyName}" (${col.moxnType})`);
669
+ }
670
+ catch (error) {
671
+ console.warn(` Warning: Failed to create scalar column "${col.notionPropertyName}": ${error instanceof Error ? error.message : error}`);
672
+ }
673
+ }
563
674
  // 3. Link entries and assign tags
564
675
  // Build a map of KB path → { documentId, branchId } from migration results
565
676
  const docByPath = new Map();
@@ -605,6 +716,35 @@ async function importNotionDatabase(client, dbImport, log, options, preCreatedDb
605
716
  }
606
717
  }
607
718
  }
719
+ // Set scalar property values
720
+ for (const [colName, scalarVal] of values.scalarValues) {
721
+ const scalarColInfo = scalarColumnMap.get(colName);
722
+ if (!scalarColInfo)
723
+ continue;
724
+ // Determine the value to send based on type
725
+ let value = null;
726
+ switch (scalarVal.moxnType) {
727
+ case 'number':
728
+ value = scalarVal.numberValue ?? null;
729
+ break;
730
+ case 'date':
731
+ value = scalarVal.dateValue ?? null;
732
+ break;
733
+ case 'text':
734
+ case 'url':
735
+ case 'email':
736
+ value = scalarVal.textValue ?? null;
737
+ break;
738
+ }
739
+ if (value != null) {
740
+ try {
741
+ await client.setPropertyValue(dbId, docInfo.documentId, colName, value);
742
+ }
743
+ catch (error) {
744
+ console.warn(` Warning: Failed to set "${colName}" on ${docInfo.documentId}: ${error instanceof Error ? error.message : error}`);
745
+ }
746
+ }
747
+ }
608
748
  }
609
749
  catch (error) {
610
750
  console.warn(` Warning: Failed to link entry to database: ${error instanceof Error ? error.message : error}`);
@@ -129,6 +129,7 @@ export interface NotionPropertySchema {
129
129
  groups: Array<{
130
130
  id: string;
131
131
  name: string;
132
+ color?: string;
132
133
  option_ids: string[];
133
134
  }>;
134
135
  };
@@ -8,17 +8,30 @@
8
8
  */
9
9
  import type { ContentBlock, SectionInput } from '../types.js';
10
10
  import type { NotionDatabase, NotionPage } from './notion-api.js';
11
- /** Parsed Notion column that maps to a Moxn DB column. */
11
+ /** Parsed Notion column that maps to a Moxn tag-based DB column. */
12
12
  export interface MappedColumn {
13
13
  notionPropertyId: string;
14
14
  notionPropertyName: string;
15
- notionType: 'select' | 'multi_select' | 'status';
16
- moxnType: 'select' | 'multi_select';
15
+ notionType: 'select' | 'multi_select' | 'status' | 'checkbox';
16
+ moxnType: 'select' | 'multi_select' | 'status' | 'checkbox';
17
17
  options: Array<{
18
18
  notionId: string;
19
19
  name: string;
20
20
  color: string;
21
21
  }>;
22
+ /** Status group metadata (only for status columns). */
23
+ groups?: Array<{
24
+ name: string;
25
+ color: string;
26
+ optionNames: string[];
27
+ }>;
28
+ }
29
+ /** Parsed Notion column that maps to a Moxn scalar DB column. */
30
+ export interface ScalarColumn {
31
+ notionPropertyId: string;
32
+ notionPropertyName: string;
33
+ notionType: 'number' | 'date' | 'url' | 'email' | 'rich_text' | 'checkbox';
34
+ moxnType: 'number' | 'date' | 'url' | 'email' | 'text';
22
35
  }
23
36
  /** Parsed Notion column that will be rendered as text in a Properties section. */
24
37
  export interface UnmappedColumn {
@@ -31,14 +44,26 @@ export interface ParsedDatabaseSchema {
31
44
  name: string;
32
45
  description: string;
33
46
  mappedColumns: MappedColumn[];
47
+ scalarColumns: ScalarColumn[];
34
48
  unmappedColumns: UnmappedColumn[];
35
49
  }
50
+ /** Scalar value for a single database entry property. */
51
+ export interface ScalarValue {
52
+ columnName: string;
53
+ moxnType: 'number' | 'date' | 'url' | 'email' | 'text';
54
+ textValue?: string | null;
55
+ numberValue?: number | null;
56
+ dateValue?: string | null;
57
+ booleanValue?: boolean | null;
58
+ }
36
59
  /** Parsed property values for a single database entry. */
37
60
  export interface ParsedEntryValues {
38
61
  /** Document name derived from title property. */
39
62
  title: string;
40
63
  /** Mapped column values: column name → selected option names. */
41
64
  tagValues: Map<string, string[]>;
65
+ /** Scalar property values: column name → scalar value. */
66
+ scalarValues: Map<string, ScalarValue>;
42
67
  /** Unmapped property values for markdown table: property name → rendered value. */
43
68
  textValues: Map<string, string>;
44
69
  /** File attachments to include as content blocks. */
@@ -17,6 +17,7 @@ export function parseDatabaseSchema(db) {
17
17
  const name = richTextToPlain(db.title) || 'Untitled Database';
18
18
  const description = richTextToPlain(db.description);
19
19
  const mappedColumns = [];
20
+ const scalarColumns = [];
20
21
  const unmappedColumns = [];
21
22
  for (const [propName, prop] of Object.entries(db.properties)) {
22
23
  // Skip title — it becomes the document name
@@ -49,17 +50,77 @@ export function parseDatabaseSchema(db) {
49
50
  });
50
51
  }
51
52
  else if (prop.type === 'status' && prop.status) {
52
- // Status is structurally identical to select
53
+ // Status → Moxn status column with group metadata
53
54
  mappedColumns.push({
54
55
  notionPropertyId: prop.id,
55
56
  notionPropertyName: propName,
56
57
  notionType: 'status',
57
- moxnType: 'select',
58
+ moxnType: 'status',
58
59
  options: prop.status.options.map((o) => ({
59
60
  notionId: o.id,
60
61
  name: o.name,
61
62
  color: o.color,
62
63
  })),
64
+ groups: prop.status.groups?.map((g) => ({
65
+ name: g.name,
66
+ color: g.color ?? 'default',
67
+ optionNames: g.option_ids
68
+ ? g.option_ids.map((id) => prop.status.options.find((o) => o.id === id)?.name ?? id)
69
+ : [],
70
+ })),
71
+ });
72
+ }
73
+ else if (prop.type === 'checkbox') {
74
+ // Checkbox → Moxn checkbox column (tag-based 2-option select)
75
+ mappedColumns.push({
76
+ notionPropertyId: prop.id,
77
+ notionPropertyName: propName,
78
+ notionType: 'checkbox',
79
+ moxnType: 'checkbox',
80
+ options: [
81
+ { notionId: 'checked', name: 'Checked', color: 'green' },
82
+ { notionId: 'unchecked', name: 'Unchecked', color: 'gray' },
83
+ ],
84
+ });
85
+ }
86
+ else if (prop.type === 'number') {
87
+ scalarColumns.push({
88
+ notionPropertyId: prop.id,
89
+ notionPropertyName: propName,
90
+ notionType: 'number',
91
+ moxnType: 'number',
92
+ });
93
+ }
94
+ else if (prop.type === 'date') {
95
+ scalarColumns.push({
96
+ notionPropertyId: prop.id,
97
+ notionPropertyName: propName,
98
+ notionType: 'date',
99
+ moxnType: 'date',
100
+ });
101
+ }
102
+ else if (prop.type === 'url') {
103
+ scalarColumns.push({
104
+ notionPropertyId: prop.id,
105
+ notionPropertyName: propName,
106
+ notionType: 'url',
107
+ moxnType: 'url',
108
+ });
109
+ }
110
+ else if (prop.type === 'email') {
111
+ scalarColumns.push({
112
+ notionPropertyId: prop.id,
113
+ notionPropertyName: propName,
114
+ notionType: 'email',
115
+ moxnType: 'email',
116
+ });
117
+ }
118
+ else if (prop.type === 'rich_text') {
119
+ scalarColumns.push({
120
+ notionPropertyId: prop.id,
121
+ notionPropertyName: propName,
122
+ notionType: 'rich_text',
123
+ moxnType: 'text',
63
124
  });
64
125
  }
65
126
  else {
@@ -74,7 +135,7 @@ export function parseDatabaseSchema(db) {
74
135
  });
75
136
  }
76
137
  }
77
- return { name, description, mappedColumns, unmappedColumns };
138
+ return { name, description, mappedColumns, scalarColumns, unmappedColumns };
78
139
  }
79
140
  // ============================================
80
141
  // Entry Parsing
@@ -85,20 +146,38 @@ export function parseDatabaseSchema(db) {
85
146
  export function parseEntryValues(page, schema) {
86
147
  const title = getPageTitle(page);
87
148
  const tagValues = new Map();
149
+ const scalarValues = new Map();
88
150
  const textValues = new Map();
89
151
  const fileBlocks = [];
90
152
  // Build lookup by property name
91
153
  const mappedByName = new Map(schema.mappedColumns.map((c) => [c.notionPropertyName, c]));
154
+ const scalarByName = new Map(schema.scalarColumns.map((c) => [c.notionPropertyName, c]));
92
155
  const unmappedByName = new Set(schema.unmappedColumns.map((c) => c.notionPropertyName));
93
156
  for (const [propName, propValue] of Object.entries(page.properties)) {
94
157
  if (propValue.type === 'title')
95
158
  continue; // Already extracted
96
- // Check if this is a mapped column
159
+ // Check if this is a tag-based mapped column
97
160
  const mapped = mappedByName.get(propName);
98
161
  if (mapped) {
99
- const values = extractSelectValues(propValue, mapped.notionType);
100
- if (values.length > 0) {
101
- tagValues.set(propName, values);
162
+ // Checkbox uses tag-based approach: extract "Checked"/"Unchecked" option name
163
+ if (mapped.moxnType === 'checkbox') {
164
+ const isChecked = propValue.checkbox === true;
165
+ tagValues.set(propName, [isChecked ? 'Checked' : 'Unchecked']);
166
+ }
167
+ else {
168
+ const values = extractSelectValues(propValue, mapped.notionType);
169
+ if (values.length > 0) {
170
+ tagValues.set(propName, values);
171
+ }
172
+ }
173
+ continue;
174
+ }
175
+ // Check if this is a scalar column
176
+ const scalar = scalarByName.get(propName);
177
+ if (scalar) {
178
+ const extracted = extractScalarValue(propValue, scalar);
179
+ if (extracted) {
180
+ scalarValues.set(propName, extracted);
102
181
  }
103
182
  continue;
104
183
  }
@@ -147,7 +226,7 @@ export function parseEntryValues(page, schema) {
147
226
  }
148
227
  }
149
228
  }
150
- return { title, tagValues, textValues, fileBlocks };
229
+ return { title, tagValues, scalarValues, textValues, fileBlocks };
151
230
  }
152
231
  /**
153
232
  * Render unmapped properties as a markdown table section.
@@ -187,6 +266,54 @@ function extractSelectValues(propValue, notionType) {
187
266
  return [];
188
267
  }
189
268
  }
269
+ function extractScalarValue(propValue, scalarCol) {
270
+ switch (scalarCol.moxnType) {
271
+ case 'number':
272
+ if (propValue.number == null)
273
+ return null;
274
+ return {
275
+ columnName: scalarCol.notionPropertyName,
276
+ moxnType: 'number',
277
+ numberValue: propValue.number,
278
+ };
279
+ case 'date':
280
+ if (!propValue.date?.start)
281
+ return null;
282
+ return {
283
+ columnName: scalarCol.notionPropertyName,
284
+ moxnType: 'date',
285
+ dateValue: propValue.date.start,
286
+ };
287
+ case 'url':
288
+ if (!propValue.url)
289
+ return null;
290
+ return {
291
+ columnName: scalarCol.notionPropertyName,
292
+ moxnType: 'url',
293
+ textValue: propValue.url,
294
+ };
295
+ case 'email':
296
+ if (!propValue.email)
297
+ return null;
298
+ return {
299
+ columnName: scalarCol.notionPropertyName,
300
+ moxnType: 'email',
301
+ textValue: propValue.email,
302
+ };
303
+ case 'text': {
304
+ const text = richTextToPlain(propValue.rich_text ?? []);
305
+ if (!text)
306
+ return null;
307
+ return {
308
+ columnName: scalarCol.notionPropertyName,
309
+ moxnType: 'text',
310
+ textValue: text,
311
+ };
312
+ }
313
+ default:
314
+ return null;
315
+ }
316
+ }
190
317
  function renderPropertyValue(prop) {
191
318
  switch (prop.type) {
192
319
  case 'rich_text':
@@ -41,6 +41,7 @@ function makeSchema(unmappedColumns) {
41
41
  name: 'Test DB',
42
42
  description: '',
43
43
  mappedColumns: [],
44
+ scalarColumns: [],
44
45
  unmappedColumns: unmappedColumns.map((name) => ({
45
46
  notionPropertyId: `prop-${name}`,
46
47
  notionPropertyName: name,
@@ -130,17 +130,62 @@ async function createNotionDatabase(ctx, parentPageId, resolved) {
130
130
  name: opt.tagName,
131
131
  color: hexToNotionColor(opt.tagColor),
132
132
  }));
133
- if (col.type === 'select') {
134
- properties[col.name] = {
135
- type: 'select',
136
- select: { options },
137
- };
138
- }
139
- else if (col.type === 'multi_select') {
140
- properties[col.name] = {
141
- type: 'multi_select',
142
- multi_select: { options },
143
- };
133
+ switch (col.type) {
134
+ case 'select':
135
+ properties[col.name] = {
136
+ type: 'select',
137
+ select: { options },
138
+ };
139
+ break;
140
+ case 'multi_select':
141
+ properties[col.name] = {
142
+ type: 'multi_select',
143
+ multi_select: { options },
144
+ };
145
+ break;
146
+ case 'status':
147
+ // Status exports as select in Notion (status requires specific API access)
148
+ properties[col.name] = {
149
+ type: 'select',
150
+ select: { options },
151
+ };
152
+ break;
153
+ case 'checkbox':
154
+ properties[col.name] = {
155
+ type: 'checkbox',
156
+ checkbox: {},
157
+ };
158
+ break;
159
+ case 'number':
160
+ properties[col.name] = {
161
+ type: 'number',
162
+ number: { format: 'number' },
163
+ };
164
+ break;
165
+ case 'date':
166
+ properties[col.name] = {
167
+ type: 'date',
168
+ date: {},
169
+ };
170
+ break;
171
+ case 'url':
172
+ properties[col.name] = {
173
+ type: 'url',
174
+ url: {},
175
+ };
176
+ break;
177
+ case 'email':
178
+ properties[col.name] = {
179
+ type: 'email',
180
+ email: {},
181
+ };
182
+ break;
183
+ case 'text':
184
+ properties[col.name] = {
185
+ type: 'rich_text',
186
+ rich_text: {},
187
+ };
188
+ break;
144
189
  }
145
190
  }
146
191
  await sleep(RATE_LIMIT_MS);
@@ -216,19 +261,84 @@ async function createDatabaseEntries(ctx, dataSourceId, resolved) {
216
261
  };
217
262
  // Add column values
218
263
  for (const [colName, propValue] of Object.entries(doc.properties)) {
219
- if (propValue.values.length === 0)
220
- continue;
221
- if (propValue.columnType === 'select') {
222
- properties[colName] = {
223
- type: 'select',
224
- select: { name: propValue.values[0].tagName },
225
- };
264
+ // Tag-based columns (select, multi_select, status, checkbox)
265
+ if ('values' in propValue) {
266
+ if (propValue.values.length === 0)
267
+ continue;
268
+ switch (propValue.columnType) {
269
+ case 'select':
270
+ case 'status':
271
+ properties[colName] = {
272
+ type: 'select',
273
+ select: { name: propValue.values[0].tagName },
274
+ };
275
+ break;
276
+ case 'multi_select':
277
+ properties[colName] = {
278
+ type: 'multi_select',
279
+ multi_select: propValue.values.map((v) => ({ name: v.tagName })),
280
+ };
281
+ break;
282
+ case 'checkbox': {
283
+ // The first tag is the "checked" option
284
+ const isChecked = propValue.values[0]?.tagName === 'Checked';
285
+ properties[colName] = {
286
+ type: 'checkbox',
287
+ checkbox: isChecked,
288
+ };
289
+ break;
290
+ }
291
+ }
226
292
  }
227
- else if (propValue.columnType === 'multi_select') {
228
- properties[colName] = {
229
- type: 'multi_select',
230
- multi_select: propValue.values.map((v) => ({ name: v.tagName })),
231
- };
293
+ else {
294
+ // Scalar columns (number, date, text, url, email)
295
+ if (propValue.textValue == null &&
296
+ propValue.numberValue == null &&
297
+ propValue.dateValue == null &&
298
+ propValue.booleanValue == null)
299
+ continue;
300
+ switch (propValue.columnType) {
301
+ case 'number':
302
+ if (propValue.numberValue != null) {
303
+ properties[colName] = {
304
+ type: 'number',
305
+ number: propValue.numberValue,
306
+ };
307
+ }
308
+ break;
309
+ case 'date':
310
+ if (propValue.dateValue) {
311
+ properties[colName] = {
312
+ type: 'date',
313
+ date: { start: propValue.dateValue },
314
+ };
315
+ }
316
+ break;
317
+ case 'url':
318
+ if (propValue.textValue) {
319
+ properties[colName] = {
320
+ type: 'url',
321
+ url: propValue.textValue,
322
+ };
323
+ }
324
+ break;
325
+ case 'email':
326
+ if (propValue.textValue) {
327
+ properties[colName] = {
328
+ type: 'email',
329
+ email: propValue.textValue,
330
+ };
331
+ }
332
+ break;
333
+ case 'text':
334
+ if (propValue.textValue) {
335
+ properties[colName] = {
336
+ type: 'rich_text',
337
+ rich_text: [{ type: 'text', text: { content: propValue.textValue } }],
338
+ };
339
+ }
340
+ break;
341
+ }
232
342
  }
233
343
  }
234
344
  await sleep(RATE_LIMIT_MS);
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.19",
3
+ "version": "0.4.21",
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",