@moxn/kb-migrate 0.4.24 → 0.4.26
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 +22 -1
- package/dist/client.js +46 -0
- package/dist/index.js +148 -6
- package/dist/sources/base.d.ts +11 -0
- package/dist/sources/notion-blocks.d.ts +4 -0
- package/dist/sources/notion-blocks.js +27 -32
- package/dist/sources/notion-databases.d.ts +3 -3
- package/dist/sources/notion-databases.js +26 -3
- package/dist/sources/notion-references.d.ts +1 -0
- package/dist/sources/notion-references.js +9 -6
- package/dist/sources/notion.d.ts +17 -0
- package/dist/sources/notion.js +39 -6
- package/dist/types.d.ts +4 -0
- package/package.json +1 -1
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>;
|
|
@@ -176,5 +176,26 @@ export declare class MoxnClient {
|
|
|
176
176
|
* Set a scalar property value on a document in a database.
|
|
177
177
|
*/
|
|
178
178
|
setPropertyValue(databaseId: string, documentId: string, columnName: string, value: string | number | boolean | null): Promise<void>;
|
|
179
|
+
/**
|
|
180
|
+
* Create or upsert a Notion page → KB document mapping.
|
|
181
|
+
*/
|
|
182
|
+
createNotionPageMapping(input: {
|
|
183
|
+
kbDocumentId: string;
|
|
184
|
+
notionPageId: string;
|
|
185
|
+
importSource: 'import' | 'export';
|
|
186
|
+
notionPageTitle?: string;
|
|
187
|
+
}): Promise<void>;
|
|
188
|
+
/**
|
|
189
|
+
* Get all Notion database → KB database mappings for the tenant.
|
|
190
|
+
* Returns a Map of notionDatabaseId → kbDatabaseId.
|
|
191
|
+
*/
|
|
192
|
+
getAllDatabaseMappings(): Promise<Map<string, string>>;
|
|
193
|
+
/**
|
|
194
|
+
* Get a KB database by ID. Returns null if not found (404).
|
|
195
|
+
*/
|
|
196
|
+
getDatabase(databaseId: string): Promise<{
|
|
197
|
+
id: string;
|
|
198
|
+
name: string;
|
|
199
|
+
} | null>;
|
|
179
200
|
private isConflictError;
|
|
180
201
|
}
|
package/dist/client.js
CHANGED
|
@@ -522,6 +522,52 @@ export class MoxnClient {
|
|
|
522
522
|
throw new Error(body.error || `Failed to set property value: ${response.status}`);
|
|
523
523
|
}
|
|
524
524
|
}
|
|
525
|
+
// ──────────────────────────────────────────────
|
|
526
|
+
// Notion page mapping methods
|
|
527
|
+
// ──────────────────────────────────────────────
|
|
528
|
+
/**
|
|
529
|
+
* Create or upsert a Notion page → KB document mapping.
|
|
530
|
+
*/
|
|
531
|
+
async createNotionPageMapping(input) {
|
|
532
|
+
const response = await fetch(`${this.apiUrl}/api/v1/kb/notion-mappings`, {
|
|
533
|
+
method: 'POST',
|
|
534
|
+
headers: {
|
|
535
|
+
'Content-Type': 'application/json',
|
|
536
|
+
'x-api-key': this.apiKey,
|
|
537
|
+
},
|
|
538
|
+
body: JSON.stringify(input),
|
|
539
|
+
});
|
|
540
|
+
if (!response.ok) {
|
|
541
|
+
const body = await response.json().catch(() => ({}));
|
|
542
|
+
throw new Error(body.error || `Failed to create page mapping: ${response.status}`);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* Get all Notion database → KB database mappings for the tenant.
|
|
547
|
+
* Returns a Map of notionDatabaseId → kbDatabaseId.
|
|
548
|
+
*/
|
|
549
|
+
async getAllDatabaseMappings() {
|
|
550
|
+
const response = await fetch(`${this.apiUrl}/api/v1/kb/notion-mappings/databases`, {
|
|
551
|
+
headers: { 'x-api-key': this.apiKey },
|
|
552
|
+
});
|
|
553
|
+
if (!response.ok) {
|
|
554
|
+
const body = await response.json().catch(() => ({}));
|
|
555
|
+
throw new Error(body.error || `Failed to get database mappings: ${response.status}`);
|
|
556
|
+
}
|
|
557
|
+
const data = await response.json();
|
|
558
|
+
const map = new Map();
|
|
559
|
+
for (const mapping of data.mappings) {
|
|
560
|
+
map.set(mapping.notionDatabaseId, mapping.kbDatabaseId);
|
|
561
|
+
}
|
|
562
|
+
return map;
|
|
563
|
+
}
|
|
564
|
+
/**
|
|
565
|
+
* Get a KB database by ID. Returns null if not found (404).
|
|
566
|
+
*/
|
|
567
|
+
async getDatabase(databaseId) {
|
|
568
|
+
const databases = await this.listDatabases();
|
|
569
|
+
return databases.find((db) => db.id === databaseId) ?? null;
|
|
570
|
+
}
|
|
525
571
|
isConflictError(error) {
|
|
526
572
|
return (error instanceof Error &&
|
|
527
573
|
'documentId' in error &&
|
package/dist/index.js
CHANGED
|
@@ -16,6 +16,8 @@ import { Command } from 'commander';
|
|
|
16
16
|
import { LocalSource } from './sources/local.js';
|
|
17
17
|
import { NotionSource } from './sources/notion.js';
|
|
18
18
|
import { notionColorToHex } from './sources/notion-api.js';
|
|
19
|
+
import { getPageTitle } from './sources/notion-blocks.js';
|
|
20
|
+
import { slugify } from './sources/notion.js';
|
|
19
21
|
import { MoxnClient } from './client.js';
|
|
20
22
|
import { runExport } from './export.js';
|
|
21
23
|
import { runNotionExport } from './export-notion.js';
|
|
@@ -42,6 +44,21 @@ async function runMigration(source, options) {
|
|
|
42
44
|
console.log(`Processing: ${doc.sourcePath}${progress}`);
|
|
43
45
|
const result = await client.migrateDocument(doc, options.basePath, options.onConflict, options.dryRun);
|
|
44
46
|
results.push(result);
|
|
47
|
+
// Store Notion page → KB document mapping (fire-and-forget)
|
|
48
|
+
if (result.sourcePageId &&
|
|
49
|
+
result.documentId &&
|
|
50
|
+
(result.status === 'created' || result.status === 'updated')) {
|
|
51
|
+
client
|
|
52
|
+
.createNotionPageMapping({
|
|
53
|
+
kbDocumentId: result.documentId,
|
|
54
|
+
notionPageId: result.sourcePageId,
|
|
55
|
+
importSource: 'import',
|
|
56
|
+
notionPageTitle: doc.metadata?.notionTitle,
|
|
57
|
+
})
|
|
58
|
+
.catch((err) => {
|
|
59
|
+
console.warn(` Warning: page mapping failed for ${result.sourcePageId}: ${err instanceof Error ? err.message : err}`);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
45
62
|
// Track consecutive failures for circuit breaker
|
|
46
63
|
if (result.status === 'failed') {
|
|
47
64
|
consecutiveFailures++;
|
|
@@ -63,12 +80,15 @@ async function runMigration(source, options) {
|
|
|
63
80
|
// Circuit breaker: abort after too many consecutive failures
|
|
64
81
|
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
|
65
82
|
console.error(`\nAborting: ${MAX_CONSECUTIVE_FAILURES} consecutive failures. ` +
|
|
66
|
-
'Last error: ' +
|
|
83
|
+
'Last error: ' +
|
|
84
|
+
(result.error || 'unknown') +
|
|
85
|
+
'\n' +
|
|
67
86
|
'Fix the underlying issue and retry. Remaining documents will be skipped.');
|
|
68
87
|
break;
|
|
69
88
|
}
|
|
70
89
|
}
|
|
71
|
-
// Build summary
|
|
90
|
+
// Build summary (include extraction stats if available)
|
|
91
|
+
const extractionStats = source.getExtractionStats?.();
|
|
72
92
|
const summary = {
|
|
73
93
|
total: results.length,
|
|
74
94
|
created: results.filter((r) => r.status === 'created').length,
|
|
@@ -76,6 +96,12 @@ async function runMigration(source, options) {
|
|
|
76
96
|
skipped: results.filter((r) => r.status === 'skipped').length,
|
|
77
97
|
failed: results.filter((r) => r.status === 'failed').length,
|
|
78
98
|
duration: Date.now() - startTime,
|
|
99
|
+
...(extractionStats && {
|
|
100
|
+
discovered: extractionStats.discovered,
|
|
101
|
+
skippedDuringExtraction: extractionStats.skippedEmpty.length +
|
|
102
|
+
extractionStats.skippedNoContent.length +
|
|
103
|
+
extractionStats.errors.length,
|
|
104
|
+
}),
|
|
79
105
|
};
|
|
80
106
|
const log = {
|
|
81
107
|
timestamp: new Date().toISOString(),
|
|
@@ -101,6 +127,15 @@ function printSummary(log) {
|
|
|
101
127
|
console.log(`Base path: ${log.basePath}`);
|
|
102
128
|
console.log(`Duration: ${(log.summary.duration / 1000).toFixed(1)}s`);
|
|
103
129
|
console.log('');
|
|
130
|
+
// Show reconciliation if extraction stats are available
|
|
131
|
+
if (log.summary.discovered != null && log.summary.skippedDuringExtraction != null) {
|
|
132
|
+
console.log(`Discovered: ${log.summary.discovered} pages`);
|
|
133
|
+
if (log.summary.skippedDuringExtraction > 0) {
|
|
134
|
+
console.log(`Skipped: ${log.summary.skippedDuringExtraction} (empty or errored during extraction)`);
|
|
135
|
+
}
|
|
136
|
+
console.log(`Processed: ${log.summary.total}`);
|
|
137
|
+
console.log('');
|
|
138
|
+
}
|
|
104
139
|
console.log(`Total: ${log.summary.total}`);
|
|
105
140
|
console.log(`Created: ${log.summary.created}`);
|
|
106
141
|
console.log(`Updated: ${log.summary.updated}`);
|
|
@@ -432,9 +467,20 @@ program
|
|
|
432
467
|
if (!opts.dryRun && dbImports.length > 0) {
|
|
433
468
|
console.log(`\nPre-creating ${dbImports.length} database(s)...`);
|
|
434
469
|
const preClient = new MoxnClient(migrationOptions);
|
|
470
|
+
// Fetch existing database mappings (1 API call) to avoid duplicates
|
|
471
|
+
let existingMappings = new Map();
|
|
472
|
+
try {
|
|
473
|
+
existingMappings = await preClient.getAllDatabaseMappings();
|
|
474
|
+
if (existingMappings.size > 0) {
|
|
475
|
+
console.log(` Found ${existingMappings.size} existing database mapping(s).`);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
catch (error) {
|
|
479
|
+
console.warn(` Warning: Could not fetch existing mappings: ${error instanceof Error ? error.message : error}`);
|
|
480
|
+
}
|
|
435
481
|
for (const dbImport of dbImports) {
|
|
436
482
|
try {
|
|
437
|
-
const kbDbId = await preCreateNotionDatabase(preClient, dbImport);
|
|
483
|
+
const kbDbId = await preCreateNotionDatabase(preClient, dbImport, existingMappings);
|
|
438
484
|
databaseIdMap.set(dbImport.notionDatabaseId, kbDbId);
|
|
439
485
|
}
|
|
440
486
|
catch (error) {
|
|
@@ -443,10 +489,77 @@ program
|
|
|
443
489
|
}
|
|
444
490
|
// Make the map available during extraction
|
|
445
491
|
source.setDatabaseIdMap(databaseIdMap);
|
|
446
|
-
console.log(` ${databaseIdMap.size} database(s)
|
|
492
|
+
console.log(` ${databaseIdMap.size} database(s) ready for embed resolution.\n`);
|
|
447
493
|
}
|
|
448
494
|
// Step 3: Run page migration (validate is idempotent, extract uses databaseIdMap)
|
|
449
495
|
const log = await runMigration(source, migrationOptions);
|
|
496
|
+
// Step 3b: Resolve inline databases discovered during extraction
|
|
497
|
+
if (!opts.dryRun) {
|
|
498
|
+
const unresolvedDbs = source.getUnresolvedInlineDatabases();
|
|
499
|
+
if (unresolvedDbs.size > 0) {
|
|
500
|
+
console.log(`\nResolving ${unresolvedDbs.size} inline database(s) discovered during extraction...`);
|
|
501
|
+
const inlineClient = new MoxnClient(migrationOptions);
|
|
502
|
+
const notionApi = source.getNotionApiClient();
|
|
503
|
+
// Fetch current mappings to avoid duplicates (may have changed since pre-create)
|
|
504
|
+
let currentMappings = new Map();
|
|
505
|
+
try {
|
|
506
|
+
currentMappings = await inlineClient.getAllDatabaseMappings();
|
|
507
|
+
}
|
|
508
|
+
catch {
|
|
509
|
+
// Non-fatal — will create new databases
|
|
510
|
+
}
|
|
511
|
+
let inlineCreated = 0;
|
|
512
|
+
for (const [notionDbId, title] of unresolvedDbs) {
|
|
513
|
+
// Skip if already mapped (could happen if previously imported)
|
|
514
|
+
if (databaseIdMap.has(notionDbId) || currentMappings.has(notionDbId)) {
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
try {
|
|
518
|
+
// Fetch database schema from Notion
|
|
519
|
+
const notionDb = await notionApi.getDatabase(notionDbId);
|
|
520
|
+
const { parseDatabaseSchema } = await import('./sources/notion-databases.js');
|
|
521
|
+
const schema = parseDatabaseSchema(notionDb);
|
|
522
|
+
// Create KB database
|
|
523
|
+
const kbDb = await inlineClient.createDatabase({
|
|
524
|
+
name: schema.name || title,
|
|
525
|
+
description: schema.description || undefined,
|
|
526
|
+
});
|
|
527
|
+
// Store mapping
|
|
528
|
+
try {
|
|
529
|
+
await inlineClient.createNotionDatabaseMapping({
|
|
530
|
+
kbDatabaseId: kbDb.id,
|
|
531
|
+
notionDatabaseId: notionDbId,
|
|
532
|
+
notionDatabaseTitle: schema.name || title,
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
catch {
|
|
536
|
+
// Non-fatal — database created but mapping may fail
|
|
537
|
+
}
|
|
538
|
+
databaseIdMap.set(notionDbId, kbDb.id);
|
|
539
|
+
// Query entries and add to dbImports for finalization
|
|
540
|
+
const entries = await notionApi.queryDatabase(notionDbId);
|
|
541
|
+
if (entries.length > 0) {
|
|
542
|
+
dbImports.push({
|
|
543
|
+
notionDatabaseId: notionDbId,
|
|
544
|
+
schema,
|
|
545
|
+
entries: entries.map((entry) => {
|
|
546
|
+
const entryTitle = getPageTitle(entry);
|
|
547
|
+
return { page: entry, kbPath: slugify(entryTitle) };
|
|
548
|
+
}),
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
inlineCreated++;
|
|
552
|
+
console.log(` Created inline database: ${schema.name || title} (${kbDb.id}) — ${entries.length} entries`);
|
|
553
|
+
}
|
|
554
|
+
catch (error) {
|
|
555
|
+
console.warn(` Warning: Failed to create inline database "${title}" (${notionDbId}): ${error instanceof Error ? error.message : error}`);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
if (inlineCreated > 0) {
|
|
559
|
+
console.log(` Created ${inlineCreated} inline database(s). Re-run with --on-conflict update to resolve embeds.`);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
450
563
|
// Step 4: Resolve cross-references (between page migration and database finalization)
|
|
451
564
|
if (!opts.dryRun) {
|
|
452
565
|
const refsWithData = log.results.filter((r) => r.references?.length && r.sectionIds?.length);
|
|
@@ -581,15 +694,26 @@ program
|
|
|
581
694
|
* Called before document extraction so child_database blocks can resolve.
|
|
582
695
|
* Returns the KB database ID.
|
|
583
696
|
*/
|
|
584
|
-
async function preCreateNotionDatabase(client, dbImport) {
|
|
697
|
+
async function preCreateNotionDatabase(client, dbImport, existingMappings) {
|
|
585
698
|
const { schema } = dbImport;
|
|
699
|
+
// Check if we already have a mapping for this Notion database
|
|
700
|
+
const existingKbDbId = existingMappings.get(dbImport.notionDatabaseId);
|
|
701
|
+
if (existingKbDbId) {
|
|
702
|
+
// Verify the KB database still exists
|
|
703
|
+
const existingDb = await client.getDatabase(existingKbDbId);
|
|
704
|
+
if (existingDb) {
|
|
705
|
+
console.log(` Reusing existing database: ${existingDb.name} (${existingKbDbId})`);
|
|
706
|
+
return existingKbDbId;
|
|
707
|
+
}
|
|
708
|
+
console.log(` Mapped database ${existingKbDbId} no longer exists, creating new one.`);
|
|
709
|
+
}
|
|
586
710
|
console.log(` Pre-creating database: ${schema.name}`);
|
|
587
711
|
const db = await client.createDatabase({
|
|
588
712
|
name: schema.name,
|
|
589
713
|
description: schema.description || undefined,
|
|
590
714
|
});
|
|
591
715
|
console.log(` Database created: ${db.id}`);
|
|
592
|
-
// Store Notion → KB database mapping
|
|
716
|
+
// Store Notion → KB database mapping (upsert handles duplicates)
|
|
593
717
|
try {
|
|
594
718
|
await client.createNotionDatabaseMapping({
|
|
595
719
|
kbDatabaseId: db.id,
|
|
@@ -688,6 +812,8 @@ async function importNotionDatabase(client, dbImport, log, options, preCreatedDb
|
|
|
688
812
|
// 3. Link entries and assign tags
|
|
689
813
|
// Build a map of KB path → { documentId, branchId } from migration results
|
|
690
814
|
const docByPath = new Map();
|
|
815
|
+
// Build a map of Notion page ID → KB document ID for page_ref resolution
|
|
816
|
+
const notionIdToDocId = new Map();
|
|
691
817
|
for (const result of log.results) {
|
|
692
818
|
if (result.documentId && result.branchId) {
|
|
693
819
|
docByPath.set(result.documentPath, {
|
|
@@ -695,6 +821,9 @@ async function importNotionDatabase(client, dbImport, log, options, preCreatedDb
|
|
|
695
821
|
branchId: result.branchId,
|
|
696
822
|
});
|
|
697
823
|
}
|
|
824
|
+
if (result.sourcePageId && result.documentId) {
|
|
825
|
+
notionIdToDocId.set(result.sourcePageId, result.documentId);
|
|
826
|
+
}
|
|
698
827
|
}
|
|
699
828
|
let linkedCount = 0;
|
|
700
829
|
for (const entry of entries) {
|
|
@@ -749,6 +878,19 @@ async function importNotionDatabase(client, dbImport, log, options, preCreatedDb
|
|
|
749
878
|
case 'email':
|
|
750
879
|
value = scalarVal.textValue ?? null;
|
|
751
880
|
break;
|
|
881
|
+
case 'page_ref': {
|
|
882
|
+
// Resolve notion:XXX markers to KB document IDs
|
|
883
|
+
const refs = JSON.parse(scalarVal.textValue ?? '[]');
|
|
884
|
+
const resolvedRefs = refs.map((ref) => {
|
|
885
|
+
if (ref.startsWith('notion:')) {
|
|
886
|
+
const notionId = ref.substring(7);
|
|
887
|
+
return notionIdToDocId.get(notionId) ?? ref;
|
|
888
|
+
}
|
|
889
|
+
return ref;
|
|
890
|
+
});
|
|
891
|
+
value = JSON.stringify(resolvedRefs);
|
|
892
|
+
break;
|
|
893
|
+
}
|
|
752
894
|
}
|
|
753
895
|
if (value != null) {
|
|
754
896
|
try {
|
package/dist/sources/base.d.ts
CHANGED
|
@@ -40,4 +40,15 @@ export declare abstract class MigrationSource<TConfig extends SourceConfig = Sou
|
|
|
40
40
|
* Returns undefined if count cannot be determined without full extraction
|
|
41
41
|
*/
|
|
42
42
|
abstract getDocumentCount(): Promise<number | undefined>;
|
|
43
|
+
/**
|
|
44
|
+
* Get extraction stats for reconciling discovered vs extracted counts.
|
|
45
|
+
* Only available after extract() completes.
|
|
46
|
+
*/
|
|
47
|
+
getExtractionStats?(): {
|
|
48
|
+
discovered: number;
|
|
49
|
+
extracted: number;
|
|
50
|
+
skippedEmpty: string[];
|
|
51
|
+
skippedNoContent: string[];
|
|
52
|
+
errors: string[];
|
|
53
|
+
};
|
|
43
54
|
}
|
|
@@ -16,6 +16,8 @@ export type UnsupportedBlockTracker = Map<string, number>;
|
|
|
16
16
|
* H2 headings create section boundaries.
|
|
17
17
|
* Content before the first H2 goes into an "Introduction" section.
|
|
18
18
|
*/
|
|
19
|
+
/** Collects Notion database IDs that were encountered but not in databaseIdMap. */
|
|
20
|
+
export type UnresolvedChildDatabaseCollector = Map<string, string>;
|
|
19
21
|
export declare function blocksToSections(blocks: NotionBlock[], client: NotionApiClient, pagePathMap: PagePathMap, options?: {
|
|
20
22
|
/** Track synced block IDs to detect cycles. */
|
|
21
23
|
visitedSyncedBlocks?: Set<string>;
|
|
@@ -23,6 +25,8 @@ export declare function blocksToSections(blocks: NotionBlock[], client: NotionAp
|
|
|
23
25
|
databaseIdMap?: Map<string, string>;
|
|
24
26
|
/** Accumulates counts of unsupported block types (caller-owned). */
|
|
25
27
|
unsupportedBlocks?: UnsupportedBlockTracker;
|
|
28
|
+
/** Collects inline databases not yet mapped (caller-owned). */
|
|
29
|
+
unresolvedChildDatabases?: UnresolvedChildDatabaseCollector;
|
|
26
30
|
}): Promise<SectionInput[]>;
|
|
27
31
|
/** Convert rich text array to markdown string. */
|
|
28
32
|
export declare function richTextToMarkdown(richText: NotionRichText[]): string;
|
|
@@ -4,19 +4,11 @@
|
|
|
4
4
|
* Section boundaries are created at H2 headings.
|
|
5
5
|
* Rich text is converted to markdown.
|
|
6
6
|
*/
|
|
7
|
-
// ============================================
|
|
8
|
-
// Main entry point
|
|
9
|
-
// ============================================
|
|
10
|
-
/**
|
|
11
|
-
* Convert a page's blocks into Moxn sections.
|
|
12
|
-
*
|
|
13
|
-
* H2 headings create section boundaries.
|
|
14
|
-
* Content before the first H2 goes into an "Introduction" section.
|
|
15
|
-
*/
|
|
16
7
|
export async function blocksToSections(blocks, client, pagePathMap, options) {
|
|
17
8
|
const visitedSyncedBlocks = options?.visitedSyncedBlocks ?? new Set();
|
|
18
9
|
const databaseIdMap = options?.databaseIdMap;
|
|
19
10
|
const unsupportedBlocks = options?.unsupportedBlocks;
|
|
11
|
+
const unresolvedChildDatabases = options?.unresolvedChildDatabases;
|
|
20
12
|
const sections = [];
|
|
21
13
|
let currentSectionName = 'Introduction';
|
|
22
14
|
let currentBlocks = [];
|
|
@@ -36,7 +28,7 @@ export async function blocksToSections(blocks, client, pagePathMap, options) {
|
|
|
36
28
|
continue;
|
|
37
29
|
}
|
|
38
30
|
// Convert block to content blocks
|
|
39
|
-
const converted = await convertBlock(block, client, pagePathMap, visitedSyncedBlocks, databaseIdMap, unsupportedBlocks);
|
|
31
|
+
const converted = await convertBlock(block, client, pagePathMap, visitedSyncedBlocks, databaseIdMap, unsupportedBlocks, unresolvedChildDatabases);
|
|
40
32
|
currentBlocks.push(...converted);
|
|
41
33
|
}
|
|
42
34
|
// Flush last section
|
|
@@ -52,7 +44,7 @@ export async function blocksToSections(blocks, client, pagePathMap, options) {
|
|
|
52
44
|
// ============================================
|
|
53
45
|
// Block conversion
|
|
54
46
|
// ============================================
|
|
55
|
-
async function convertBlock(block, client, pagePathMap, visitedSyncedBlocks, databaseIdMap, unsupportedBlocks) {
|
|
47
|
+
async function convertBlock(block, client, pagePathMap, visitedSyncedBlocks, databaseIdMap, unsupportedBlocks, unresolvedChildDatabases) {
|
|
56
48
|
const results = [];
|
|
57
49
|
switch (block.type) {
|
|
58
50
|
case 'paragraph':
|
|
@@ -82,10 +74,10 @@ async function convertBlock(block, client, pagePathMap, visitedSyncedBlocks, dat
|
|
|
82
74
|
results.push(...convertToDo(block));
|
|
83
75
|
break;
|
|
84
76
|
case 'quote':
|
|
85
|
-
results.push(...(await convertQuote(block, client, pagePathMap, visitedSyncedBlocks, databaseIdMap, unsupportedBlocks)));
|
|
77
|
+
results.push(...(await convertQuote(block, client, pagePathMap, visitedSyncedBlocks, databaseIdMap, unsupportedBlocks, unresolvedChildDatabases)));
|
|
86
78
|
break;
|
|
87
79
|
case 'callout':
|
|
88
|
-
results.push(...(await convertCallout(block, client, pagePathMap, visitedSyncedBlocks, databaseIdMap, unsupportedBlocks)));
|
|
80
|
+
results.push(...(await convertCallout(block, client, pagePathMap, visitedSyncedBlocks, databaseIdMap, unsupportedBlocks, unresolvedChildDatabases)));
|
|
89
81
|
break;
|
|
90
82
|
case 'divider':
|
|
91
83
|
results.push(textBlock('---'));
|
|
@@ -94,7 +86,7 @@ async function convertBlock(block, client, pagePathMap, visitedSyncedBlocks, dat
|
|
|
94
86
|
results.push(...(await convertTable(block, client)));
|
|
95
87
|
break;
|
|
96
88
|
case 'toggle':
|
|
97
|
-
results.push(...(await convertToggle(block, client, pagePathMap, visitedSyncedBlocks, databaseIdMap, unsupportedBlocks)));
|
|
89
|
+
results.push(...(await convertToggle(block, client, pagePathMap, visitedSyncedBlocks, databaseIdMap, unsupportedBlocks, unresolvedChildDatabases)));
|
|
98
90
|
break;
|
|
99
91
|
case 'bookmark':
|
|
100
92
|
results.push(...convertBookmark(block));
|
|
@@ -120,13 +112,13 @@ async function convertBlock(block, client, pagePathMap, visitedSyncedBlocks, dat
|
|
|
120
112
|
// Just note it in the content.
|
|
121
113
|
break;
|
|
122
114
|
case 'child_database':
|
|
123
|
-
results.push(...convertChildDatabase(block, databaseIdMap));
|
|
115
|
+
results.push(...convertChildDatabase(block, databaseIdMap, unresolvedChildDatabases));
|
|
124
116
|
break;
|
|
125
117
|
case 'synced_block':
|
|
126
|
-
results.push(...(await convertSyncedBlock(block, client, pagePathMap, visitedSyncedBlocks, databaseIdMap, unsupportedBlocks)));
|
|
118
|
+
results.push(...(await convertSyncedBlock(block, client, pagePathMap, visitedSyncedBlocks, databaseIdMap, unsupportedBlocks, unresolvedChildDatabases)));
|
|
127
119
|
break;
|
|
128
120
|
case 'column_list':
|
|
129
|
-
results.push(...(await convertColumnList(block, client, pagePathMap, visitedSyncedBlocks, databaseIdMap, unsupportedBlocks)));
|
|
121
|
+
results.push(...(await convertColumnList(block, client, pagePathMap, visitedSyncedBlocks, databaseIdMap, unsupportedBlocks, unresolvedChildDatabases)));
|
|
130
122
|
break;
|
|
131
123
|
case 'column':
|
|
132
124
|
// Columns are handled by column_list
|
|
@@ -169,7 +161,7 @@ async function convertBlock(block, client, pagePathMap, visitedSyncedBlocks, dat
|
|
|
169
161
|
? ' '
|
|
170
162
|
: undefined;
|
|
171
163
|
const children = await client.getBlockChildren(block.id);
|
|
172
|
-
const childBlocks = await convertAndMergeChildren(children, client, pagePathMap, visitedSyncedBlocks, indent, databaseIdMap, unsupportedBlocks);
|
|
164
|
+
const childBlocks = await convertAndMergeChildren(children, client, pagePathMap, visitedSyncedBlocks, indent, databaseIdMap, unsupportedBlocks, unresolvedChildDatabases);
|
|
173
165
|
results.push(...childBlocks);
|
|
174
166
|
}
|
|
175
167
|
return results;
|
|
@@ -215,7 +207,7 @@ function convertToDo(block) {
|
|
|
215
207
|
const text = richTextToMarkdown(td.to_do.rich_text);
|
|
216
208
|
return [textBlock(`${checkbox} ${text}`)];
|
|
217
209
|
}
|
|
218
|
-
async function convertQuote(block, client, pagePathMap, visitedSyncedBlocks, databaseIdMap, unsupportedBlocks) {
|
|
210
|
+
async function convertQuote(block, client, pagePathMap, visitedSyncedBlocks, databaseIdMap, unsupportedBlocks, unresolvedChildDatabases) {
|
|
219
211
|
const q = block;
|
|
220
212
|
const text = richTextToMarkdown(q.quote.rich_text);
|
|
221
213
|
if (!text && !block.has_children)
|
|
@@ -229,12 +221,12 @@ async function convertQuote(block, client, pagePathMap, visitedSyncedBlocks, dat
|
|
|
229
221
|
const results = quoted ? [textBlock(quoted)] : [];
|
|
230
222
|
if (block.has_children) {
|
|
231
223
|
const children = await client.getBlockChildren(block.id);
|
|
232
|
-
const childBlocks = await convertAndMergeChildren(children, client, pagePathMap, visitedSyncedBlocks, '> ', databaseIdMap, unsupportedBlocks);
|
|
224
|
+
const childBlocks = await convertAndMergeChildren(children, client, pagePathMap, visitedSyncedBlocks, '> ', databaseIdMap, unsupportedBlocks, unresolvedChildDatabases);
|
|
233
225
|
results.push(...childBlocks);
|
|
234
226
|
}
|
|
235
227
|
return results;
|
|
236
228
|
}
|
|
237
|
-
async function convertCallout(block, client, pagePathMap, visitedSyncedBlocks, databaseIdMap, unsupportedBlocks) {
|
|
229
|
+
async function convertCallout(block, client, pagePathMap, visitedSyncedBlocks, databaseIdMap, unsupportedBlocks, unresolvedChildDatabases) {
|
|
238
230
|
const c = block;
|
|
239
231
|
const text = richTextToMarkdown(c.callout.rich_text);
|
|
240
232
|
const emoji = c.callout.icon?.emoji ?? '';
|
|
@@ -246,7 +238,7 @@ async function convertCallout(block, client, pagePathMap, visitedSyncedBlocks, d
|
|
|
246
238
|
const results = [textBlock(quoted)];
|
|
247
239
|
if (block.has_children) {
|
|
248
240
|
const children = await client.getBlockChildren(block.id);
|
|
249
|
-
const childBlocks = await convertAndMergeChildren(children, client, pagePathMap, visitedSyncedBlocks, '> ', databaseIdMap, unsupportedBlocks);
|
|
241
|
+
const childBlocks = await convertAndMergeChildren(children, client, pagePathMap, visitedSyncedBlocks, '> ', databaseIdMap, unsupportedBlocks, unresolvedChildDatabases);
|
|
250
242
|
results.push(...childBlocks);
|
|
251
243
|
}
|
|
252
244
|
return results;
|
|
@@ -275,18 +267,18 @@ async function convertTable(block, client) {
|
|
|
275
267
|
}
|
|
276
268
|
return [textBlock(lines.join('\n'))];
|
|
277
269
|
}
|
|
278
|
-
async function convertToggle(block, client, pagePathMap, visitedSyncedBlocks, databaseIdMap, unsupportedBlocks) {
|
|
270
|
+
async function convertToggle(block, client, pagePathMap, visitedSyncedBlocks, databaseIdMap, unsupportedBlocks, unresolvedChildDatabases) {
|
|
279
271
|
const t = block;
|
|
280
272
|
const header = richTextToMarkdown(t.toggle.rich_text);
|
|
281
273
|
const results = [textBlock(`**${header}**`)];
|
|
282
274
|
if (block.has_children) {
|
|
283
275
|
const children = await client.getBlockChildren(block.id);
|
|
284
|
-
const childBlocks = await convertAndMergeChildren(children, client, pagePathMap, visitedSyncedBlocks, '> ', databaseIdMap, unsupportedBlocks);
|
|
276
|
+
const childBlocks = await convertAndMergeChildren(children, client, pagePathMap, visitedSyncedBlocks, '> ', databaseIdMap, unsupportedBlocks, unresolvedChildDatabases);
|
|
285
277
|
results.push(...childBlocks);
|
|
286
278
|
}
|
|
287
279
|
return results;
|
|
288
280
|
}
|
|
289
|
-
function convertChildDatabase(block, databaseIdMap) {
|
|
281
|
+
function convertChildDatabase(block, databaseIdMap, unresolvedChildDatabases) {
|
|
290
282
|
const cd = block;
|
|
291
283
|
const title = cd.child_database?.title || 'Untitled Database';
|
|
292
284
|
const notionDbId = normalizeId(block.id);
|
|
@@ -295,7 +287,10 @@ function convertChildDatabase(block, databaseIdMap) {
|
|
|
295
287
|
if (kbDatabaseId) {
|
|
296
288
|
return [{ blockType: 'database_embed', databaseId: kbDatabaseId }];
|
|
297
289
|
}
|
|
298
|
-
// No mapping available — emit text placeholder
|
|
290
|
+
// No mapping available — collect for later creation and emit text placeholder
|
|
291
|
+
if (unresolvedChildDatabases) {
|
|
292
|
+
unresolvedChildDatabases.set(notionDbId, title);
|
|
293
|
+
}
|
|
299
294
|
console.warn(` No KB mapping for Notion database "${title}" (${notionDbId})`);
|
|
300
295
|
return [textBlock(`*(Embedded database: ${title})*`)];
|
|
301
296
|
}
|
|
@@ -406,7 +401,7 @@ function convertLinkToPage(block, pagePathMap) {
|
|
|
406
401
|
// Target not in import set
|
|
407
402
|
return [textBlock(`*(Link to Notion page: ${targetId})*`)];
|
|
408
403
|
}
|
|
409
|
-
async function convertSyncedBlock(block, client, pagePathMap, visitedSyncedBlocks, databaseIdMap, unsupportedBlocks) {
|
|
404
|
+
async function convertSyncedBlock(block, client, pagePathMap, visitedSyncedBlocks, databaseIdMap, unsupportedBlocks, unresolvedChildDatabases) {
|
|
410
405
|
const sb = block;
|
|
411
406
|
// Get the source block ID (either this block or the original)
|
|
412
407
|
const sourceId = sb.synced_block.synced_from?.block_id ?? block.id;
|
|
@@ -417,19 +412,19 @@ async function convertSyncedBlock(block, client, pagePathMap, visitedSyncedBlock
|
|
|
417
412
|
visitedSyncedBlocks.add(sourceId);
|
|
418
413
|
try {
|
|
419
414
|
const children = await client.getBlockChildren(sourceId);
|
|
420
|
-
return await convertAndMergeChildren(children, client, pagePathMap, visitedSyncedBlocks, undefined, databaseIdMap, unsupportedBlocks);
|
|
415
|
+
return await convertAndMergeChildren(children, client, pagePathMap, visitedSyncedBlocks, undefined, databaseIdMap, unsupportedBlocks, unresolvedChildDatabases);
|
|
421
416
|
}
|
|
422
417
|
finally {
|
|
423
418
|
visitedSyncedBlocks.delete(sourceId);
|
|
424
419
|
}
|
|
425
420
|
}
|
|
426
|
-
async function convertColumnList(block, client, pagePathMap, visitedSyncedBlocks, databaseIdMap, unsupportedBlocks) {
|
|
421
|
+
async function convertColumnList(block, client, pagePathMap, visitedSyncedBlocks, databaseIdMap, unsupportedBlocks, unresolvedChildDatabases) {
|
|
427
422
|
const children = await client.getBlockChildren(block.id);
|
|
428
423
|
const results = [];
|
|
429
424
|
for (const column of children) {
|
|
430
425
|
if (column.type === 'column' && column.has_children) {
|
|
431
426
|
const columnChildren = await client.getBlockChildren(column.id);
|
|
432
|
-
const converted = await convertAndMergeChildren(columnChildren, client, pagePathMap, visitedSyncedBlocks, undefined, databaseIdMap, unsupportedBlocks);
|
|
427
|
+
const converted = await convertAndMergeChildren(columnChildren, client, pagePathMap, visitedSyncedBlocks, undefined, databaseIdMap, unsupportedBlocks, unresolvedChildDatabases);
|
|
433
428
|
results.push(...converted);
|
|
434
429
|
}
|
|
435
430
|
}
|
|
@@ -578,10 +573,10 @@ function mergeConsecutiveListBlocks(blocks) {
|
|
|
578
573
|
/**
|
|
579
574
|
* Convert children blocks, merge consecutive list items, and optionally indent.
|
|
580
575
|
*/
|
|
581
|
-
async function convertAndMergeChildren(children, client, pagePathMap, visitedSyncedBlocks, indent, databaseIdMap, unsupportedBlocks) {
|
|
576
|
+
async function convertAndMergeChildren(children, client, pagePathMap, visitedSyncedBlocks, indent, databaseIdMap, unsupportedBlocks, unresolvedChildDatabases) {
|
|
582
577
|
const blocks = [];
|
|
583
578
|
for (const child of children) {
|
|
584
|
-
const converted = await convertBlock(child, client, pagePathMap, visitedSyncedBlocks, databaseIdMap, unsupportedBlocks);
|
|
579
|
+
const converted = await convertBlock(child, client, pagePathMap, visitedSyncedBlocks, databaseIdMap, unsupportedBlocks, unresolvedChildDatabases);
|
|
585
580
|
blocks.push(...converted);
|
|
586
581
|
}
|
|
587
582
|
const merged = mergeConsecutiveListBlocks(blocks);
|
|
@@ -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
|
-
|
|
179
|
-
if (
|
|
180
|
-
|
|
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
|
}
|
|
@@ -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
|
|
135
|
+
displayText,
|
|
134
136
|
});
|
|
135
|
-
return `[${
|
|
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
|
}
|
package/dist/sources/notion.d.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import type { ExtractedDocument } from '../types.js';
|
|
11
11
|
import { MigrationSource, type SourceConfig } from './base.js';
|
|
12
|
+
import { NotionApiClient } from './notion-api.js';
|
|
12
13
|
import type { NotionPage, NotionDatabase } from './notion-api.js';
|
|
13
14
|
import { type PagePathMap } from './notion-blocks.js';
|
|
14
15
|
import { type ParsedDatabaseSchema } from './notion-databases.js';
|
|
@@ -48,6 +49,7 @@ export declare class NotionSource extends MigrationSource<NotionSourceConfig> {
|
|
|
48
49
|
private pageTree;
|
|
49
50
|
private allPages;
|
|
50
51
|
private pagePathMap;
|
|
52
|
+
private pageTitleMap;
|
|
51
53
|
private databases;
|
|
52
54
|
private databaseEntryPageIds;
|
|
53
55
|
private _documentCount;
|
|
@@ -59,6 +61,8 @@ export declare class NotionSource extends MigrationSource<NotionSourceConfig> {
|
|
|
59
61
|
private _unsupportedBlocks;
|
|
60
62
|
/** Map of Notion database ID → KB database ID, set before extraction. */
|
|
61
63
|
private _databaseIdMap;
|
|
64
|
+
/** Collects inline databases encountered during extraction but not in databaseIdMap. */
|
|
65
|
+
private _unresolvedChildDatabases;
|
|
62
66
|
constructor(config: NotionSourceConfig);
|
|
63
67
|
/**
|
|
64
68
|
* Set the database ID map (Notion DB ID → KB DB ID).
|
|
@@ -76,6 +80,18 @@ export declare class NotionSource extends MigrationSource<NotionSourceConfig> {
|
|
|
76
80
|
*/
|
|
77
81
|
getDatabaseImports(): NotionDatabaseImportInfo[];
|
|
78
82
|
extract(): AsyncGenerator<ExtractedDocument, void, unknown>;
|
|
83
|
+
/** Get extraction stats for reconciling discovered vs extracted counts. */
|
|
84
|
+
getExtractionStats(): {
|
|
85
|
+
discovered: number;
|
|
86
|
+
extracted: number;
|
|
87
|
+
skippedEmpty: string[];
|
|
88
|
+
skippedNoContent: string[];
|
|
89
|
+
errors: string[];
|
|
90
|
+
};
|
|
91
|
+
/** Get unresolved inline databases found during extraction. */
|
|
92
|
+
getUnresolvedInlineDatabases(): Map<string, string>;
|
|
93
|
+
/** Expose the Notion API client (for fetching inline DB schemas). */
|
|
94
|
+
getNotionApiClient(): NotionApiClient;
|
|
79
95
|
/** Clean up temp files after migration completes. */
|
|
80
96
|
cleanup(): Promise<void>;
|
|
81
97
|
private buildPageTreeInternal;
|
|
@@ -96,6 +112,7 @@ export interface PageTreeResult {
|
|
|
96
112
|
tree: PageTreeNode[];
|
|
97
113
|
allPages: PageTreeNode[];
|
|
98
114
|
pagePathMap: PagePathMap;
|
|
115
|
+
pageTitleMap: Map<string, string>;
|
|
99
116
|
databaseEntryPageIds: Set<string>;
|
|
100
117
|
}
|
|
101
118
|
/**
|
package/dist/sources/notion.js
CHANGED
|
@@ -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;
|
|
@@ -36,6 +37,8 @@ export class NotionSource extends MigrationSource {
|
|
|
36
37
|
_unsupportedBlocks = new Map();
|
|
37
38
|
/** Map of Notion database ID → KB database ID, set before extraction. */
|
|
38
39
|
_databaseIdMap = new Map();
|
|
40
|
+
/** Collects inline databases encountered during extraction but not in databaseIdMap. */
|
|
41
|
+
_unresolvedChildDatabases = new Map();
|
|
39
42
|
constructor(config) {
|
|
40
43
|
super(config);
|
|
41
44
|
this.client = new NotionApiClient(config.token);
|
|
@@ -156,6 +159,7 @@ export class NotionSource extends MigrationSource {
|
|
|
156
159
|
this._skippedNoContent = [];
|
|
157
160
|
this._extractionErrors = [];
|
|
158
161
|
this._unsupportedBlocks = new Map();
|
|
162
|
+
this._unresolvedChildDatabases = new Map();
|
|
159
163
|
let yielded = 0;
|
|
160
164
|
// Walk pages depth-first
|
|
161
165
|
for (const node of this.allPages) {
|
|
@@ -224,6 +228,27 @@ export class NotionSource extends MigrationSource {
|
|
|
224
228
|
console.log(` ⚠ Count mismatch: expected ${expectedYield} (${this._documentCount} - ${totalSkipped} skipped) but extracted ${yielded}`);
|
|
225
229
|
}
|
|
226
230
|
}
|
|
231
|
+
/** Get extraction stats for reconciling discovered vs extracted counts. */
|
|
232
|
+
getExtractionStats() {
|
|
233
|
+
const totalSkipped = this._skippedEmpty.length +
|
|
234
|
+
this._skippedNoContent.length +
|
|
235
|
+
this._extractionErrors.length;
|
|
236
|
+
return {
|
|
237
|
+
discovered: this._documentCount,
|
|
238
|
+
extracted: this._documentCount - totalSkipped,
|
|
239
|
+
skippedEmpty: [...this._skippedEmpty],
|
|
240
|
+
skippedNoContent: [...this._skippedNoContent],
|
|
241
|
+
errors: [...this._extractionErrors],
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
/** Get unresolved inline databases found during extraction. */
|
|
245
|
+
getUnresolvedInlineDatabases() {
|
|
246
|
+
return new Map(this._unresolvedChildDatabases);
|
|
247
|
+
}
|
|
248
|
+
/** Expose the Notion API client (for fetching inline DB schemas). */
|
|
249
|
+
getNotionApiClient() {
|
|
250
|
+
return this.client;
|
|
251
|
+
}
|
|
227
252
|
/** Clean up temp files after migration completes. */
|
|
228
253
|
async cleanup() {
|
|
229
254
|
await this.mediaDownloader.cleanup();
|
|
@@ -240,6 +265,7 @@ export class NotionSource extends MigrationSource {
|
|
|
240
265
|
this.pageTree = result.tree;
|
|
241
266
|
this.allPages = result.allPages;
|
|
242
267
|
this.pagePathMap = result.pagePathMap;
|
|
268
|
+
this.pageTitleMap = result.pageTitleMap;
|
|
243
269
|
this.databaseEntryPageIds = result.databaseEntryPageIds;
|
|
244
270
|
}
|
|
245
271
|
// ============================================
|
|
@@ -287,11 +313,12 @@ export class NotionSource extends MigrationSource {
|
|
|
287
313
|
let sections = await blocksToSections(blocks, this.client, this.pagePathMap, {
|
|
288
314
|
databaseIdMap: this._databaseIdMap.size > 0 ? this._databaseIdMap : undefined,
|
|
289
315
|
unsupportedBlocks: this._unsupportedBlocks,
|
|
316
|
+
unresolvedChildDatabases: this._unresolvedChildDatabases,
|
|
290
317
|
});
|
|
291
318
|
// Download Notion-hosted media files
|
|
292
319
|
sections = await this.downloadSectionMedia(sections);
|
|
293
320
|
// Resolve cross-references
|
|
294
|
-
const { sections: resolvedSections, references } = resolveNotionReferences(sections, { notionIdToKbPath: this.pagePathMap });
|
|
321
|
+
const { sections: resolvedSections, references } = resolveNotionReferences(sections, { notionIdToKbPath: this.pagePathMap, notionIdToTitle: this.pageTitleMap });
|
|
295
322
|
sections = resolvedSections;
|
|
296
323
|
if (sections.length === 0) {
|
|
297
324
|
this._skippedNoContent.push(node.title);
|
|
@@ -333,13 +360,14 @@ export class NotionSource extends MigrationSource {
|
|
|
333
360
|
const contentSections = await blocksToSections(blocks, this.client, this.pagePathMap, {
|
|
334
361
|
databaseIdMap: this._databaseIdMap.size > 0 ? this._databaseIdMap : undefined,
|
|
335
362
|
unsupportedBlocks: this._unsupportedBlocks,
|
|
363
|
+
unresolvedChildDatabases: this._unresolvedChildDatabases,
|
|
336
364
|
});
|
|
337
365
|
sections.push(...contentSections);
|
|
338
366
|
}
|
|
339
367
|
// Download media
|
|
340
368
|
let processedSections = await this.downloadSectionMedia(sections);
|
|
341
369
|
// Resolve cross-references
|
|
342
|
-
const { sections: resolvedSections, references } = resolveNotionReferences(processedSections, { notionIdToKbPath: this.pagePathMap });
|
|
370
|
+
const { sections: resolvedSections, references } = resolveNotionReferences(processedSections, { notionIdToKbPath: this.pagePathMap, notionIdToTitle: this.pageTitleMap });
|
|
343
371
|
processedSections = resolvedSections;
|
|
344
372
|
const nid = normalizeId(entry.id);
|
|
345
373
|
const kbPath = this.pagePathMap.get(nid) ?? slug;
|
|
@@ -392,6 +420,7 @@ export class NotionSource extends MigrationSource {
|
|
|
392
420
|
*/
|
|
393
421
|
export function buildPageTree(pages, options) {
|
|
394
422
|
const pagePathMap = new Map();
|
|
423
|
+
const pageTitleMap = new Map();
|
|
395
424
|
const databaseEntryPageIds = new Set();
|
|
396
425
|
const tree = [];
|
|
397
426
|
const allPages = [];
|
|
@@ -493,7 +522,11 @@ export function buildPageTree(pages, options) {
|
|
|
493
522
|
}
|
|
494
523
|
// Build tree recursively
|
|
495
524
|
const buildNode = (page, parentPath, depth, siblingSlugCounts) => {
|
|
496
|
-
|
|
525
|
+
const nid = normalizeId(page.id);
|
|
526
|
+
const isDatabaseSubtree = syntheticDatabaseIds.has(nid) || databaseEntryPageIds.has(nid);
|
|
527
|
+
if (options?.maxDepth !== undefined &&
|
|
528
|
+
depth > options.maxDepth &&
|
|
529
|
+
!isDatabaseSubtree) {
|
|
497
530
|
return null;
|
|
498
531
|
}
|
|
499
532
|
const title = getPageTitle(page);
|
|
@@ -505,7 +538,6 @@ export function buildPageTree(pages, options) {
|
|
|
505
538
|
slug = `${slug}-${existing + 1}`;
|
|
506
539
|
}
|
|
507
540
|
const kbPath = parentPath ? `${parentPath}/${slug}` : slug;
|
|
508
|
-
const nid = normalizeId(page.id);
|
|
509
541
|
const isDatabaseEntry = databaseEntryPageIds.has(nid);
|
|
510
542
|
const node = {
|
|
511
543
|
page,
|
|
@@ -517,8 +549,9 @@ export function buildPageTree(pages, options) {
|
|
|
517
549
|
parentDatabaseId: page.parent.type === 'database_id' ? page.parent.database_id : undefined,
|
|
518
550
|
children: [],
|
|
519
551
|
};
|
|
520
|
-
// Register in path map
|
|
552
|
+
// Register in path map and title map
|
|
521
553
|
pagePathMap.set(nid, kbPath);
|
|
554
|
+
pageTitleMap.set(nid, title);
|
|
522
555
|
// Process children
|
|
523
556
|
const childPages = childrenByParent.get(nid) ?? [];
|
|
524
557
|
const childSlugCounts = new Map();
|
|
@@ -546,7 +579,7 @@ export function buildPageTree(pages, options) {
|
|
|
546
579
|
}
|
|
547
580
|
};
|
|
548
581
|
flatten(tree);
|
|
549
|
-
return { tree, allPages, pagePathMap, databaseEntryPageIds };
|
|
582
|
+
return { tree, allPages, pagePathMap, pageTitleMap, databaseEntryPageIds };
|
|
550
583
|
}
|
|
551
584
|
/** Extract the parent page ID from a Notion page's parent field. */
|
|
552
585
|
function getParentPageId(page) {
|
package/dist/types.d.ts
CHANGED
|
@@ -172,6 +172,10 @@ export interface MigrationLog {
|
|
|
172
172
|
skipped: number;
|
|
173
173
|
failed: number;
|
|
174
174
|
duration: number;
|
|
175
|
+
/** Upper-bound page count from discovery (before extraction skips). */
|
|
176
|
+
discovered?: number;
|
|
177
|
+
/** Pages skipped during extraction (empty, no content, errors). */
|
|
178
|
+
skippedDuringExtraction?: number;
|
|
175
179
|
};
|
|
176
180
|
}
|
|
177
181
|
/**
|
package/package.json
CHANGED