@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 +32 -3
- package/dist/client.js +42 -0
- package/dist/index.js +141 -1
- package/dist/sources/notion-api.d.ts +1 -0
- package/dist/sources/notion-databases.d.ts +28 -3
- package/dist/sources/notion-databases.js +135 -8
- package/dist/sources/notion-databases.test.js +1 -0
- package/dist/targets/notion-database-export.js +133 -23
- package/dist/types.d.ts +6 -0
- package/package.json +1 -1
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:
|
|
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:
|
|
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:
|
|
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}`);
|
|
@@ -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
|
|
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: '
|
|
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
|
-
|
|
100
|
-
if (
|
|
101
|
-
|
|
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':
|
|
@@ -130,17 +130,62 @@ async function createNotionDatabase(ctx, parentPageId, resolved) {
|
|
|
130
130
|
name: opt.tagName,
|
|
131
131
|
color: hexToNotionColor(opt.tagColor),
|
|
132
132
|
}));
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
select:
|
|
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
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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