@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 +15 -0
- package/dist/client.js +25 -0
- package/dist/index.js +91 -1
- package/dist/sources/notion.d.ts +33 -2
- package/dist/sources/notion.js +138 -117
- package/dist/targets/notion-database-export.js +4 -1
- package/dist/types.d.ts +6 -0
- package/package.json +1 -1
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:
|
|
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);
|
package/dist/sources/notion.d.ts
CHANGED
|
@@ -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
|
|
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.
|
package/dist/sources/notion.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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 &&
|
|
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