@moxn/kb-migrate 0.4.25 → 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 +21 -0
- package/dist/client.js +46 -0
- package/dist/index.js +127 -5
- 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.d.ts +15 -0
- package/dist/sources/notion.js +26 -0
- package/dist/types.d.ts +4 -0
- package/package.json +1 -1
package/dist/client.d.ts
CHANGED
|
@@ -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++;
|
|
@@ -70,7 +87,8 @@ async function runMigration(source, options) {
|
|
|
70
87
|
break;
|
|
71
88
|
}
|
|
72
89
|
}
|
|
73
|
-
// Build summary
|
|
90
|
+
// Build summary (include extraction stats if available)
|
|
91
|
+
const extractionStats = source.getExtractionStats?.();
|
|
74
92
|
const summary = {
|
|
75
93
|
total: results.length,
|
|
76
94
|
created: results.filter((r) => r.status === 'created').length,
|
|
@@ -78,6 +96,12 @@ async function runMigration(source, options) {
|
|
|
78
96
|
skipped: results.filter((r) => r.status === 'skipped').length,
|
|
79
97
|
failed: results.filter((r) => r.status === 'failed').length,
|
|
80
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
|
+
}),
|
|
81
105
|
};
|
|
82
106
|
const log = {
|
|
83
107
|
timestamp: new Date().toISOString(),
|
|
@@ -103,6 +127,15 @@ function printSummary(log) {
|
|
|
103
127
|
console.log(`Base path: ${log.basePath}`);
|
|
104
128
|
console.log(`Duration: ${(log.summary.duration / 1000).toFixed(1)}s`);
|
|
105
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
|
+
}
|
|
106
139
|
console.log(`Total: ${log.summary.total}`);
|
|
107
140
|
console.log(`Created: ${log.summary.created}`);
|
|
108
141
|
console.log(`Updated: ${log.summary.updated}`);
|
|
@@ -434,9 +467,20 @@ program
|
|
|
434
467
|
if (!opts.dryRun && dbImports.length > 0) {
|
|
435
468
|
console.log(`\nPre-creating ${dbImports.length} database(s)...`);
|
|
436
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
|
+
}
|
|
437
481
|
for (const dbImport of dbImports) {
|
|
438
482
|
try {
|
|
439
|
-
const kbDbId = await preCreateNotionDatabase(preClient, dbImport);
|
|
483
|
+
const kbDbId = await preCreateNotionDatabase(preClient, dbImport, existingMappings);
|
|
440
484
|
databaseIdMap.set(dbImport.notionDatabaseId, kbDbId);
|
|
441
485
|
}
|
|
442
486
|
catch (error) {
|
|
@@ -445,10 +489,77 @@ program
|
|
|
445
489
|
}
|
|
446
490
|
// Make the map available during extraction
|
|
447
491
|
source.setDatabaseIdMap(databaseIdMap);
|
|
448
|
-
console.log(` ${databaseIdMap.size} database(s)
|
|
492
|
+
console.log(` ${databaseIdMap.size} database(s) ready for embed resolution.\n`);
|
|
449
493
|
}
|
|
450
494
|
// Step 3: Run page migration (validate is idempotent, extract uses databaseIdMap)
|
|
451
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
|
+
}
|
|
452
563
|
// Step 4: Resolve cross-references (between page migration and database finalization)
|
|
453
564
|
if (!opts.dryRun) {
|
|
454
565
|
const refsWithData = log.results.filter((r) => r.references?.length && r.sectionIds?.length);
|
|
@@ -583,15 +694,26 @@ program
|
|
|
583
694
|
* Called before document extraction so child_database blocks can resolve.
|
|
584
695
|
* Returns the KB database ID.
|
|
585
696
|
*/
|
|
586
|
-
async function preCreateNotionDatabase(client, dbImport) {
|
|
697
|
+
async function preCreateNotionDatabase(client, dbImport, existingMappings) {
|
|
587
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
|
+
}
|
|
588
710
|
console.log(` Pre-creating database: ${schema.name}`);
|
|
589
711
|
const db = await client.createDatabase({
|
|
590
712
|
name: schema.name,
|
|
591
713
|
description: schema.description || undefined,
|
|
592
714
|
});
|
|
593
715
|
console.log(` Database created: ${db.id}`);
|
|
594
|
-
// Store Notion → KB database mapping
|
|
716
|
+
// Store Notion → KB database mapping (upsert handles duplicates)
|
|
595
717
|
try {
|
|
596
718
|
await client.createNotionDatabaseMapping({
|
|
597
719
|
kbDatabaseId: db.id,
|
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);
|
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';
|
|
@@ -60,6 +61,8 @@ export declare class NotionSource extends MigrationSource<NotionSourceConfig> {
|
|
|
60
61
|
private _unsupportedBlocks;
|
|
61
62
|
/** Map of Notion database ID → KB database ID, set before extraction. */
|
|
62
63
|
private _databaseIdMap;
|
|
64
|
+
/** Collects inline databases encountered during extraction but not in databaseIdMap. */
|
|
65
|
+
private _unresolvedChildDatabases;
|
|
63
66
|
constructor(config: NotionSourceConfig);
|
|
64
67
|
/**
|
|
65
68
|
* Set the database ID map (Notion DB ID → KB DB ID).
|
|
@@ -77,6 +80,18 @@ export declare class NotionSource extends MigrationSource<NotionSourceConfig> {
|
|
|
77
80
|
*/
|
|
78
81
|
getDatabaseImports(): NotionDatabaseImportInfo[];
|
|
79
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;
|
|
80
95
|
/** Clean up temp files after migration completes. */
|
|
81
96
|
cleanup(): Promise<void>;
|
|
82
97
|
private buildPageTreeInternal;
|
package/dist/sources/notion.js
CHANGED
|
@@ -37,6 +37,8 @@ export class NotionSource extends MigrationSource {
|
|
|
37
37
|
_unsupportedBlocks = new Map();
|
|
38
38
|
/** Map of Notion database ID → KB database ID, set before extraction. */
|
|
39
39
|
_databaseIdMap = new Map();
|
|
40
|
+
/** Collects inline databases encountered during extraction but not in databaseIdMap. */
|
|
41
|
+
_unresolvedChildDatabases = new Map();
|
|
40
42
|
constructor(config) {
|
|
41
43
|
super(config);
|
|
42
44
|
this.client = new NotionApiClient(config.token);
|
|
@@ -157,6 +159,7 @@ export class NotionSource extends MigrationSource {
|
|
|
157
159
|
this._skippedNoContent = [];
|
|
158
160
|
this._extractionErrors = [];
|
|
159
161
|
this._unsupportedBlocks = new Map();
|
|
162
|
+
this._unresolvedChildDatabases = new Map();
|
|
160
163
|
let yielded = 0;
|
|
161
164
|
// Walk pages depth-first
|
|
162
165
|
for (const node of this.allPages) {
|
|
@@ -225,6 +228,27 @@ export class NotionSource extends MigrationSource {
|
|
|
225
228
|
console.log(` ⚠ Count mismatch: expected ${expectedYield} (${this._documentCount} - ${totalSkipped} skipped) but extracted ${yielded}`);
|
|
226
229
|
}
|
|
227
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
|
+
}
|
|
228
252
|
/** Clean up temp files after migration completes. */
|
|
229
253
|
async cleanup() {
|
|
230
254
|
await this.mediaDownloader.cleanup();
|
|
@@ -289,6 +313,7 @@ export class NotionSource extends MigrationSource {
|
|
|
289
313
|
let sections = await blocksToSections(blocks, this.client, this.pagePathMap, {
|
|
290
314
|
databaseIdMap: this._databaseIdMap.size > 0 ? this._databaseIdMap : undefined,
|
|
291
315
|
unsupportedBlocks: this._unsupportedBlocks,
|
|
316
|
+
unresolvedChildDatabases: this._unresolvedChildDatabases,
|
|
292
317
|
});
|
|
293
318
|
// Download Notion-hosted media files
|
|
294
319
|
sections = await this.downloadSectionMedia(sections);
|
|
@@ -335,6 +360,7 @@ export class NotionSource extends MigrationSource {
|
|
|
335
360
|
const contentSections = await blocksToSections(blocks, this.client, this.pagePathMap, {
|
|
336
361
|
databaseIdMap: this._databaseIdMap.size > 0 ? this._databaseIdMap : undefined,
|
|
337
362
|
unsupportedBlocks: this._unsupportedBlocks,
|
|
363
|
+
unresolvedChildDatabases: this._unresolvedChildDatabases,
|
|
338
364
|
});
|
|
339
365
|
sections.push(...contentSections);
|
|
340
366
|
}
|
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