@moxn/kb-migrate 0.4.6 → 0.4.8
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 +69 -1
- package/dist/client.js +65 -1
- package/dist/date-filter.d.ts +31 -0
- package/dist/date-filter.js +83 -0
- package/dist/export-notion.d.ts +22 -0
- package/dist/export-notion.js +242 -0
- package/dist/export.js +14 -1
- package/dist/index.js +218 -18
- package/dist/sources/local.d.ts +3 -0
- package/dist/sources/local.js +31 -1
- package/dist/sources/notion-blocks.d.ts +2 -0
- package/dist/sources/notion-blocks.js +52 -24
- package/dist/sources/notion-databases.js +4 -0
- package/dist/sources/notion-media.js +1 -1
- package/dist/sources/notion.d.ts +11 -0
- package/dist/sources/notion.js +44 -3
- package/dist/targets/base.d.ts +77 -0
- package/dist/targets/base.js +21 -0
- package/dist/targets/index.d.ts +6 -0
- package/dist/targets/index.js +6 -0
- package/dist/targets/notion-database-export.d.ts +36 -0
- package/dist/targets/notion-database-export.js +255 -0
- package/dist/targets/notion.d.ts +93 -0
- package/dist/targets/notion.js +486 -0
- package/dist/types.d.ts +18 -2
- package/package.json +23 -1
package/dist/sources/notion.d.ts
CHANGED
|
@@ -15,6 +15,8 @@ export interface NotionSourceConfig extends SourceConfig {
|
|
|
15
15
|
token: string;
|
|
16
16
|
rootPageId?: string;
|
|
17
17
|
maxDepth?: number;
|
|
18
|
+
/** Date filter for source pages */
|
|
19
|
+
dateFilter?: import('../date-filter.js').DateFilter;
|
|
18
20
|
}
|
|
19
21
|
/** Info yielded to the migration runner for database import (post-document pass). */
|
|
20
22
|
export interface NotionDatabaseImportInfo {
|
|
@@ -38,7 +40,16 @@ export declare class NotionSource extends MigrationSource<NotionSourceConfig> {
|
|
|
38
40
|
private databases;
|
|
39
41
|
private databaseEntryPageIds;
|
|
40
42
|
private _documentCount;
|
|
43
|
+
private _validated;
|
|
44
|
+
/** Map of Notion database ID → KB database ID, set before extraction. */
|
|
45
|
+
private _databaseIdMap;
|
|
41
46
|
constructor(config: NotionSourceConfig);
|
|
47
|
+
/**
|
|
48
|
+
* Set the database ID map (Notion DB ID → KB DB ID).
|
|
49
|
+
* Must be called after validate() and before extract() so that
|
|
50
|
+
* child_database blocks can be converted to database_embed blocks.
|
|
51
|
+
*/
|
|
52
|
+
setDatabaseIdMap(map: Map<string, string>): void;
|
|
42
53
|
get sourceType(): string;
|
|
43
54
|
get sourceLocation(): string;
|
|
44
55
|
validate(): Promise<void>;
|
package/dist/sources/notion.js
CHANGED
|
@@ -27,11 +27,22 @@ export class NotionSource extends MigrationSource {
|
|
|
27
27
|
databases = [];
|
|
28
28
|
databaseEntryPageIds = new Set();
|
|
29
29
|
_documentCount = 0;
|
|
30
|
+
_validated = false;
|
|
31
|
+
/** Map of Notion database ID → KB database ID, set before extraction. */
|
|
32
|
+
_databaseIdMap = new Map();
|
|
30
33
|
constructor(config) {
|
|
31
34
|
super(config);
|
|
32
35
|
this.client = new NotionApiClient(config.token);
|
|
33
36
|
this.mediaDownloader = new NotionMediaDownloader();
|
|
34
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* Set the database ID map (Notion DB ID → KB DB ID).
|
|
40
|
+
* Must be called after validate() and before extract() so that
|
|
41
|
+
* child_database blocks can be converted to database_embed blocks.
|
|
42
|
+
*/
|
|
43
|
+
setDatabaseIdMap(map) {
|
|
44
|
+
this._databaseIdMap = map;
|
|
45
|
+
}
|
|
35
46
|
get sourceType() {
|
|
36
47
|
return 'notion';
|
|
37
48
|
}
|
|
@@ -44,14 +55,31 @@ export class NotionSource extends MigrationSource {
|
|
|
44
55
|
// Pass 1: Discovery (validate)
|
|
45
56
|
// ============================================
|
|
46
57
|
async validate() {
|
|
58
|
+
// Idempotent — safe to call multiple times (e.g. once explicitly for
|
|
59
|
+
// pre-creating databases, then again inside runMigration).
|
|
60
|
+
if (this._validated)
|
|
61
|
+
return;
|
|
47
62
|
// 1. Test token
|
|
48
63
|
console.log('Validating Notion API token...');
|
|
49
64
|
await this.client.validateToken();
|
|
50
65
|
console.log(' Token valid.');
|
|
51
66
|
// 2. Discover all pages
|
|
52
67
|
console.log('Discovering pages...');
|
|
53
|
-
|
|
68
|
+
let allNotionPages = await this.client.searchPages();
|
|
54
69
|
console.log(` Found ${allNotionPages.length} pages in workspace.`);
|
|
70
|
+
// 2b. Apply date filter if configured
|
|
71
|
+
if (this.config.dateFilter) {
|
|
72
|
+
const { matchesDateFilter } = await import('../date-filter.js');
|
|
73
|
+
const beforeCount = allNotionPages.length;
|
|
74
|
+
allNotionPages = allNotionPages.filter((page) => matchesDateFilter(this.config.dateFilter, {
|
|
75
|
+
createdAt: page.created_time,
|
|
76
|
+
modifiedAt: page.last_edited_time,
|
|
77
|
+
}));
|
|
78
|
+
const skipped = beforeCount - allNotionPages.length;
|
|
79
|
+
if (skipped > 0) {
|
|
80
|
+
console.log(` Filtered ${skipped} pages by date criteria`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
55
83
|
// 3. Discover all databases
|
|
56
84
|
console.log('Discovering databases...');
|
|
57
85
|
const allNotionDatabases = await this.client.searchDatabases();
|
|
@@ -78,6 +106,7 @@ export class NotionSource extends MigrationSource {
|
|
|
78
106
|
console.log(` ${this.allPages.length} pages + ${this.databases.length} databases ready for import.`);
|
|
79
107
|
// 7. Initialize media downloader
|
|
80
108
|
await this.mediaDownloader.init();
|
|
109
|
+
this._validated = true;
|
|
81
110
|
}
|
|
82
111
|
async getDocumentCount() {
|
|
83
112
|
return this._documentCount;
|
|
@@ -300,7 +329,9 @@ export class NotionSource extends MigrationSource {
|
|
|
300
329
|
console.log(` Skipping empty page: ${node.title}`);
|
|
301
330
|
return null;
|
|
302
331
|
}
|
|
303
|
-
let sections = await blocksToSections(blocks, this.client, this.pagePathMap
|
|
332
|
+
let sections = await blocksToSections(blocks, this.client, this.pagePathMap, {
|
|
333
|
+
databaseIdMap: this._databaseIdMap.size > 0 ? this._databaseIdMap : undefined,
|
|
334
|
+
});
|
|
304
335
|
// Download Notion-hosted media files
|
|
305
336
|
sections = await this.downloadSectionMedia(sections);
|
|
306
337
|
// Resolve cross-references
|
|
@@ -316,6 +347,10 @@ export class NotionSource extends MigrationSource {
|
|
|
316
347
|
sections,
|
|
317
348
|
sourcePath: `notion://${node.page.id}`,
|
|
318
349
|
references: references.length > 0 ? references : undefined,
|
|
350
|
+
metadata: {
|
|
351
|
+
notionPageId: normalizeId(node.page.id),
|
|
352
|
+
notionTitle: node.title,
|
|
353
|
+
},
|
|
319
354
|
};
|
|
320
355
|
}
|
|
321
356
|
catch (error) {
|
|
@@ -338,7 +373,9 @@ export class NotionSource extends MigrationSource {
|
|
|
338
373
|
// Get page blocks
|
|
339
374
|
const blocks = await this.client.getBlockChildren(entry.id);
|
|
340
375
|
if (blocks.length > 0) {
|
|
341
|
-
const contentSections = await blocksToSections(blocks, this.client, this.pagePathMap
|
|
376
|
+
const contentSections = await blocksToSections(blocks, this.client, this.pagePathMap, {
|
|
377
|
+
databaseIdMap: this._databaseIdMap.size > 0 ? this._databaseIdMap : undefined,
|
|
378
|
+
});
|
|
342
379
|
sections.push(...contentSections);
|
|
343
380
|
}
|
|
344
381
|
// Download media
|
|
@@ -361,6 +398,10 @@ export class NotionSource extends MigrationSource {
|
|
|
361
398
|
],
|
|
362
399
|
sourcePath: `notion://${entry.id}`,
|
|
363
400
|
references: references.length > 0 ? references : undefined,
|
|
401
|
+
metadata: {
|
|
402
|
+
notionPageId: normalizeId(entry.id),
|
|
403
|
+
notionTitle: title,
|
|
404
|
+
},
|
|
364
405
|
};
|
|
365
406
|
}
|
|
366
407
|
catch (error) {
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base interface for export targets
|
|
3
|
+
*
|
|
4
|
+
* Each target (Notion, Google Docs, etc.) implements this interface
|
|
5
|
+
* to provide a consistent way to export KB documents.
|
|
6
|
+
*/
|
|
7
|
+
import type { DocumentDetail, DocumentListItem } from '../types.js';
|
|
8
|
+
/**
|
|
9
|
+
* Result of exporting a single document to an external target
|
|
10
|
+
*/
|
|
11
|
+
export interface ExportTargetResult {
|
|
12
|
+
documentId: string;
|
|
13
|
+
documentPath: string;
|
|
14
|
+
status: 'created' | 'updated' | 'skipped' | 'failed';
|
|
15
|
+
externalId?: string;
|
|
16
|
+
error?: string;
|
|
17
|
+
duration: number;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Summary log for an export-to-target operation
|
|
21
|
+
*/
|
|
22
|
+
export interface ExportTargetLog {
|
|
23
|
+
timestamp: string;
|
|
24
|
+
sourceApi: string;
|
|
25
|
+
target: {
|
|
26
|
+
type: string;
|
|
27
|
+
location: string;
|
|
28
|
+
};
|
|
29
|
+
basePath: string;
|
|
30
|
+
options: {
|
|
31
|
+
dryRun: boolean;
|
|
32
|
+
conflictStrategy: 'skip' | 'update';
|
|
33
|
+
};
|
|
34
|
+
results: ExportTargetResult[];
|
|
35
|
+
summary: {
|
|
36
|
+
total: number;
|
|
37
|
+
created: number;
|
|
38
|
+
updated: number;
|
|
39
|
+
skipped: number;
|
|
40
|
+
failed: number;
|
|
41
|
+
duration: number;
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Configuration for export targets
|
|
46
|
+
*/
|
|
47
|
+
export interface ExportTargetConfig {
|
|
48
|
+
[key: string]: unknown;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Abstract base class for export targets
|
|
52
|
+
*/
|
|
53
|
+
export declare abstract class ExportTarget<TConfig extends ExportTargetConfig = ExportTargetConfig> {
|
|
54
|
+
protected config: TConfig;
|
|
55
|
+
constructor(config: TConfig);
|
|
56
|
+
/**
|
|
57
|
+
* Human-readable name of the target type
|
|
58
|
+
*/
|
|
59
|
+
abstract get targetType(): string;
|
|
60
|
+
/**
|
|
61
|
+
* Human-readable location identifier for logging
|
|
62
|
+
*/
|
|
63
|
+
abstract get targetLocation(): string;
|
|
64
|
+
/**
|
|
65
|
+
* Validate the target configuration and connectivity
|
|
66
|
+
* @throws Error if configuration is invalid or target is not accessible
|
|
67
|
+
*/
|
|
68
|
+
abstract validate(): Promise<void>;
|
|
69
|
+
/**
|
|
70
|
+
* Export a single document to the target
|
|
71
|
+
*/
|
|
72
|
+
abstract exportDocument(doc: DocumentDetail, listItem: DocumentListItem, dryRun: boolean): Promise<ExportTargetResult>;
|
|
73
|
+
/**
|
|
74
|
+
* Clean up resources after export completes
|
|
75
|
+
*/
|
|
76
|
+
cleanup(): Promise<void>;
|
|
77
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base interface for export targets
|
|
3
|
+
*
|
|
4
|
+
* Each target (Notion, Google Docs, etc.) implements this interface
|
|
5
|
+
* to provide a consistent way to export KB documents.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Abstract base class for export targets
|
|
9
|
+
*/
|
|
10
|
+
export class ExportTarget {
|
|
11
|
+
config;
|
|
12
|
+
constructor(config) {
|
|
13
|
+
this.config = config;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Clean up resources after export completes
|
|
17
|
+
*/
|
|
18
|
+
async cleanup() {
|
|
19
|
+
// Default no-op, override if needed
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Target registry and factory
|
|
3
|
+
*/
|
|
4
|
+
export { ExportTarget, type ExportTargetConfig, type ExportTargetResult, type ExportTargetLog } from './base.js';
|
|
5
|
+
export { NotionExportTarget, type NotionExportConfig } from './notion.js';
|
|
6
|
+
export { exportDatabase, type DatabaseExportResult, type DatabaseExportContext } from './notion-database-export.js';
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Notion Database Export
|
|
3
|
+
*
|
|
4
|
+
* Exports KB databases to Notion databases.
|
|
5
|
+
* Handles:
|
|
6
|
+
* - Creating new Notion databases from KB database schemas
|
|
7
|
+
* - Updating existing Notion databases (clear + recreate entries)
|
|
8
|
+
* - Column mapping (select/multi_select backed by tags -> Notion select/multi_select)
|
|
9
|
+
* - Color mapping (hex -> Notion color names)
|
|
10
|
+
* - Entry creation with correct property values
|
|
11
|
+
*/
|
|
12
|
+
import { Client } from '@notionhq/client';
|
|
13
|
+
import type { MoxnClient } from '../client.js';
|
|
14
|
+
export interface DatabaseExportResult {
|
|
15
|
+
kbDatabaseId: string;
|
|
16
|
+
kbDatabaseName: string;
|
|
17
|
+
notionDatabaseId: string;
|
|
18
|
+
status: 'created' | 'updated' | 'skipped' | 'failed';
|
|
19
|
+
entriesCreated: number;
|
|
20
|
+
error?: string;
|
|
21
|
+
}
|
|
22
|
+
export interface DatabaseExportContext {
|
|
23
|
+
notionClient: Client;
|
|
24
|
+
moxnClient: MoxnClient;
|
|
25
|
+
conflictStrategy: 'skip' | 'update';
|
|
26
|
+
dryRun: boolean;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Export a single KB database to Notion.
|
|
30
|
+
*
|
|
31
|
+
* @param ctx Export context with Notion client, Moxn client, and options
|
|
32
|
+
* @param kbDatabaseId KB database ID to export
|
|
33
|
+
* @param parentNotionPageId Notion page ID to create the database under
|
|
34
|
+
* @returns Export result
|
|
35
|
+
*/
|
|
36
|
+
export declare function exportDatabase(ctx: DatabaseExportContext, kbDatabaseId: string, parentNotionPageId: string): Promise<DatabaseExportResult>;
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Notion Database Export
|
|
3
|
+
*
|
|
4
|
+
* Exports KB databases to Notion databases.
|
|
5
|
+
* Handles:
|
|
6
|
+
* - Creating new Notion databases from KB database schemas
|
|
7
|
+
* - Updating existing Notion databases (clear + recreate entries)
|
|
8
|
+
* - Column mapping (select/multi_select backed by tags -> Notion select/multi_select)
|
|
9
|
+
* - Color mapping (hex -> Notion color names)
|
|
10
|
+
* - Entry creation with correct property values
|
|
11
|
+
*/
|
|
12
|
+
const RATE_LIMIT_MS = 350;
|
|
13
|
+
function sleep(ms) {
|
|
14
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
15
|
+
}
|
|
16
|
+
// Reverse mapping: hex color -> Notion color name
|
|
17
|
+
const HEX_TO_NOTION_COLOR = {
|
|
18
|
+
'#6B7280': 'gray',
|
|
19
|
+
'#92400E': 'brown',
|
|
20
|
+
'#F97316': 'orange',
|
|
21
|
+
'#EAB308': 'yellow',
|
|
22
|
+
'#22C55E': 'green',
|
|
23
|
+
'#3B82F6': 'blue',
|
|
24
|
+
'#8B5CF6': 'purple',
|
|
25
|
+
'#EC4899': 'pink',
|
|
26
|
+
'#EF4444': 'red',
|
|
27
|
+
};
|
|
28
|
+
function hexToNotionColor(hex) {
|
|
29
|
+
if (!hex)
|
|
30
|
+
return 'default';
|
|
31
|
+
return HEX_TO_NOTION_COLOR[hex.toUpperCase()] ?? HEX_TO_NOTION_COLOR[hex] ?? 'default';
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Export a single KB database to Notion.
|
|
35
|
+
*
|
|
36
|
+
* @param ctx Export context with Notion client, Moxn client, and options
|
|
37
|
+
* @param kbDatabaseId KB database ID to export
|
|
38
|
+
* @param parentNotionPageId Notion page ID to create the database under
|
|
39
|
+
* @returns Export result
|
|
40
|
+
*/
|
|
41
|
+
export async function exportDatabase(ctx, kbDatabaseId, parentNotionPageId) {
|
|
42
|
+
try {
|
|
43
|
+
// 1. Fetch resolved database from KB API
|
|
44
|
+
const resolved = await ctx.moxnClient.resolveDatabase(kbDatabaseId);
|
|
45
|
+
// 2. Check for existing Notion database mapping
|
|
46
|
+
let existingMapping = null;
|
|
47
|
+
try {
|
|
48
|
+
const mappingResult = await ctx.moxnClient.getNotionDatabaseMapping(kbDatabaseId);
|
|
49
|
+
existingMapping = mappingResult.mapping;
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// No mapping found -- that's fine
|
|
53
|
+
}
|
|
54
|
+
// 3. Handle conflict strategy
|
|
55
|
+
if (existingMapping) {
|
|
56
|
+
if (ctx.conflictStrategy === 'skip') {
|
|
57
|
+
return {
|
|
58
|
+
kbDatabaseId,
|
|
59
|
+
kbDatabaseName: resolved.databaseName,
|
|
60
|
+
notionDatabaseId: existingMapping.notionDatabaseId,
|
|
61
|
+
status: 'skipped',
|
|
62
|
+
entriesCreated: 0,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
if (ctx.dryRun) {
|
|
66
|
+
return {
|
|
67
|
+
kbDatabaseId,
|
|
68
|
+
kbDatabaseName: resolved.databaseName,
|
|
69
|
+
notionDatabaseId: existingMapping.notionDatabaseId,
|
|
70
|
+
status: 'updated',
|
|
71
|
+
entriesCreated: resolved.documents.length,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
// Update existing: archive all pages, then recreate entries
|
|
75
|
+
const entriesCreated = await updateExistingNotionDatabase(ctx, existingMapping.notionDatabaseId, resolved);
|
|
76
|
+
return {
|
|
77
|
+
kbDatabaseId,
|
|
78
|
+
kbDatabaseName: resolved.databaseName,
|
|
79
|
+
notionDatabaseId: existingMapping.notionDatabaseId,
|
|
80
|
+
status: 'updated',
|
|
81
|
+
entriesCreated,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
// 4. No existing mapping -- create new Notion database
|
|
85
|
+
if (ctx.dryRun) {
|
|
86
|
+
return {
|
|
87
|
+
kbDatabaseId,
|
|
88
|
+
kbDatabaseName: resolved.databaseName,
|
|
89
|
+
notionDatabaseId: 'dry-run',
|
|
90
|
+
status: 'created',
|
|
91
|
+
entriesCreated: resolved.documents.length,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
const { notionDatabaseId, dataSourceId } = await createNotionDatabase(ctx, parentNotionPageId, resolved);
|
|
95
|
+
// 5. Create entries
|
|
96
|
+
const entriesCreated = await createDatabaseEntries(ctx, dataSourceId, resolved);
|
|
97
|
+
// 6. Upsert mapping
|
|
98
|
+
await ctx.moxnClient.createNotionDatabaseMapping({
|
|
99
|
+
kbDatabaseId,
|
|
100
|
+
notionDatabaseId: notionDatabaseId.replace(/-/g, ''),
|
|
101
|
+
notionDatabaseTitle: resolved.databaseName,
|
|
102
|
+
});
|
|
103
|
+
return {
|
|
104
|
+
kbDatabaseId,
|
|
105
|
+
kbDatabaseName: resolved.databaseName,
|
|
106
|
+
notionDatabaseId: notionDatabaseId.replace(/-/g, ''),
|
|
107
|
+
status: 'created',
|
|
108
|
+
entriesCreated,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
return {
|
|
113
|
+
kbDatabaseId,
|
|
114
|
+
kbDatabaseName: 'Unknown',
|
|
115
|
+
notionDatabaseId: '',
|
|
116
|
+
status: 'failed',
|
|
117
|
+
entriesCreated: 0,
|
|
118
|
+
error: error instanceof Error ? error.message : String(error),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Create a new Notion database with the correct schema.
|
|
124
|
+
*/
|
|
125
|
+
async function createNotionDatabase(ctx, parentPageId, resolved) {
|
|
126
|
+
// Build properties from KB columns
|
|
127
|
+
const properties = {};
|
|
128
|
+
for (const col of resolved.columns) {
|
|
129
|
+
const options = col.options.map((opt) => ({
|
|
130
|
+
name: opt.tagName,
|
|
131
|
+
color: hexToNotionColor(opt.tagColor),
|
|
132
|
+
}));
|
|
133
|
+
if (col.type === 'select') {
|
|
134
|
+
properties[col.name] = {
|
|
135
|
+
type: 'select',
|
|
136
|
+
select: { options },
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
else if (col.type === 'multi_select') {
|
|
140
|
+
properties[col.name] = {
|
|
141
|
+
type: 'multi_select',
|
|
142
|
+
multi_select: { options },
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
await sleep(RATE_LIMIT_MS);
|
|
147
|
+
// Use client.request() for v5 API compatibility
|
|
148
|
+
// Properties go under initial_data_source.properties (not top-level)
|
|
149
|
+
const response = (await ctx.notionClient.request({
|
|
150
|
+
method: 'post',
|
|
151
|
+
path: 'databases',
|
|
152
|
+
body: {
|
|
153
|
+
parent: { type: 'page_id', page_id: parentPageId },
|
|
154
|
+
title: [{ type: 'text', text: { content: resolved.databaseName } }],
|
|
155
|
+
initial_data_source: {
|
|
156
|
+
type: 'external',
|
|
157
|
+
external: {},
|
|
158
|
+
properties,
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
}));
|
|
162
|
+
const notionDatabaseId = response.id;
|
|
163
|
+
const dataSourceId = response.data_sources?.[0]?.id ?? notionDatabaseId;
|
|
164
|
+
return { notionDatabaseId, dataSourceId };
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Update an existing Notion database by archiving all pages and recreating entries.
|
|
168
|
+
*/
|
|
169
|
+
async function updateExistingNotionDatabase(ctx, notionDatabaseId, resolved) {
|
|
170
|
+
// Archive existing pages (Notion v5: query database, archive each page)
|
|
171
|
+
await sleep(RATE_LIMIT_MS);
|
|
172
|
+
let hasMore = true;
|
|
173
|
+
let startCursor;
|
|
174
|
+
while (hasMore) {
|
|
175
|
+
const queryResult = await ctx.notionClient.databases.query({
|
|
176
|
+
database_id: notionDatabaseId,
|
|
177
|
+
start_cursor: startCursor,
|
|
178
|
+
page_size: 100,
|
|
179
|
+
});
|
|
180
|
+
for (const page of queryResult.results) {
|
|
181
|
+
await sleep(RATE_LIMIT_MS);
|
|
182
|
+
try {
|
|
183
|
+
await ctx.notionClient.pages.update({
|
|
184
|
+
page_id: page.id,
|
|
185
|
+
archived: true,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
// Continue if individual archive fails
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
hasMore = queryResult.has_more;
|
|
193
|
+
startCursor = queryResult.next_cursor ?? undefined;
|
|
194
|
+
}
|
|
195
|
+
// Now get the data_source_id for entry creation
|
|
196
|
+
// Query the database to get its data_sources
|
|
197
|
+
await sleep(RATE_LIMIT_MS);
|
|
198
|
+
const dbInfo = (await ctx.notionClient.request({
|
|
199
|
+
method: 'get',
|
|
200
|
+
path: `databases/${notionDatabaseId}`,
|
|
201
|
+
}));
|
|
202
|
+
const dataSourceId = dbInfo.data_sources?.[0]?.id ?? notionDatabaseId;
|
|
203
|
+
// Create new entries
|
|
204
|
+
return createDatabaseEntries(ctx, dataSourceId, resolved);
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Create database entries as Notion pages.
|
|
208
|
+
*/
|
|
209
|
+
async function createDatabaseEntries(ctx, dataSourceId, resolved) {
|
|
210
|
+
let created = 0;
|
|
211
|
+
for (const doc of resolved.documents) {
|
|
212
|
+
try {
|
|
213
|
+
// Build property values for this entry
|
|
214
|
+
const properties = {
|
|
215
|
+
// Title property (always "Name" for databases)
|
|
216
|
+
Name: {
|
|
217
|
+
type: 'title',
|
|
218
|
+
title: [{ type: 'text', text: { content: doc.documentName ?? 'Untitled' } }],
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
// Add column values
|
|
222
|
+
for (const [colName, propValue] of Object.entries(doc.properties)) {
|
|
223
|
+
if (propValue.values.length === 0)
|
|
224
|
+
continue;
|
|
225
|
+
if (propValue.columnType === 'select') {
|
|
226
|
+
properties[colName] = {
|
|
227
|
+
type: 'select',
|
|
228
|
+
select: { name: propValue.values[0].tagName },
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
else if (propValue.columnType === 'multi_select') {
|
|
232
|
+
properties[colName] = {
|
|
233
|
+
type: 'multi_select',
|
|
234
|
+
multi_select: propValue.values.map((v) => ({ name: v.tagName })),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
await sleep(RATE_LIMIT_MS);
|
|
239
|
+
// Create entry as page with data_source_id parent
|
|
240
|
+
await ctx.notionClient.request({
|
|
241
|
+
method: 'post',
|
|
242
|
+
path: 'pages',
|
|
243
|
+
body: {
|
|
244
|
+
parent: { type: 'data_source_id', data_source_id: dataSourceId },
|
|
245
|
+
properties,
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
created++;
|
|
249
|
+
}
|
|
250
|
+
catch (error) {
|
|
251
|
+
console.error(` Warning: Failed to create entry "${doc.documentName}": ${error instanceof Error ? error.message : error}`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return created;
|
|
255
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NotionExportTarget — exports KB documents to Notion pages.
|
|
3
|
+
*
|
|
4
|
+
* Converts KB section content (markdown) to Notion blocks using @tryfabric/martian,
|
|
5
|
+
* then creates or updates Notion pages via the Notion API.
|
|
6
|
+
*
|
|
7
|
+
* Supports a two-pass export algorithm:
|
|
8
|
+
* - Pass 1: Create all pages (without cross-references)
|
|
9
|
+
* - Pass 2: Append reference blocks using the KB doc ID -> Notion page ID mapping
|
|
10
|
+
*
|
|
11
|
+
* Conflict detection uses the MoxnClient's notion-mapping API endpoints
|
|
12
|
+
* (when available) to find existing Notion pages for a KB document.
|
|
13
|
+
*/
|
|
14
|
+
import { ExportTarget, type ExportTargetConfig, type ExportTargetResult } from './base.js';
|
|
15
|
+
import type { DocumentDetail, DocumentListItem } from '../types.js';
|
|
16
|
+
export interface NotionExportConfig extends ExportTargetConfig {
|
|
17
|
+
/** Notion integration token */
|
|
18
|
+
notionToken: string;
|
|
19
|
+
/** Parent page ID under which to create pages */
|
|
20
|
+
parentPageId: string;
|
|
21
|
+
/** How to handle pages that already exist in Notion */
|
|
22
|
+
conflictStrategy: 'skip' | 'update';
|
|
23
|
+
/** Moxn API URL (for notion-mapping lookups) */
|
|
24
|
+
apiUrl: string;
|
|
25
|
+
/** Moxn API key */
|
|
26
|
+
apiKey: string;
|
|
27
|
+
}
|
|
28
|
+
/** A cross-reference extracted from section content */
|
|
29
|
+
export interface ExtractedKBReference {
|
|
30
|
+
/** The KB document ID of the source document */
|
|
31
|
+
sourceDocumentId: string;
|
|
32
|
+
/** Target KB path found in markdown links */
|
|
33
|
+
targetKbPath: string;
|
|
34
|
+
/** Display text for the reference */
|
|
35
|
+
displayText: string;
|
|
36
|
+
}
|
|
37
|
+
export declare class NotionExportTarget extends ExportTarget<NotionExportConfig> {
|
|
38
|
+
private client;
|
|
39
|
+
/** Cache: kbDocumentId -> notionPageId (from mapping API) */
|
|
40
|
+
private mappingCache;
|
|
41
|
+
/** Cache: notionPageId -> true (pages we've verified exist) */
|
|
42
|
+
private verifiedPages;
|
|
43
|
+
/** Map from KB path prefix -> Notion page ID (for nested page creation) */
|
|
44
|
+
private pathToNotionId;
|
|
45
|
+
/** KB path -> KB document ID mapping (built during two-pass export) */
|
|
46
|
+
private kbPathToDocId;
|
|
47
|
+
/** KB doc ID -> Notion page ID mapping (built during pass 1) */
|
|
48
|
+
private docIdToNotionPageId;
|
|
49
|
+
constructor(config: NotionExportConfig);
|
|
50
|
+
get targetType(): string;
|
|
51
|
+
get targetLocation(): string;
|
|
52
|
+
validate(): Promise<void>;
|
|
53
|
+
/**
|
|
54
|
+
* Register a KB document in the path -> ID index.
|
|
55
|
+
* Called before pass 1 so references can be resolved in pass 2.
|
|
56
|
+
*/
|
|
57
|
+
registerDocument(doc: DocumentDetail): void;
|
|
58
|
+
/**
|
|
59
|
+
* After pass 1, record a successful export mapping.
|
|
60
|
+
* Called by the runner when a doc is created/updated successfully.
|
|
61
|
+
*/
|
|
62
|
+
registerExportedPage(kbDocumentId: string, notionPageId: string): void;
|
|
63
|
+
/**
|
|
64
|
+
* Pass 2: Resolve references for a document and append link blocks to its Notion page.
|
|
65
|
+
*
|
|
66
|
+
* Finds KB path links in the document content, resolves them to Notion page IDs
|
|
67
|
+
* (from the current export batch or pre-existing mappings), and appends
|
|
68
|
+
* mention/link blocks to the Notion page.
|
|
69
|
+
*
|
|
70
|
+
* Returns the number of references resolved.
|
|
71
|
+
*/
|
|
72
|
+
resolveAndAppendReferences(doc: DocumentDetail, notionPageId: string): Promise<{
|
|
73
|
+
resolved: number;
|
|
74
|
+
unresolved: number;
|
|
75
|
+
}>;
|
|
76
|
+
/**
|
|
77
|
+
* Resolve a KB path to a Notion page ID.
|
|
78
|
+
*
|
|
79
|
+
* Checks three sources in order:
|
|
80
|
+
* 1. Current export batch (docIdToNotionPageId from pass 1)
|
|
81
|
+
* 2. Local mapping cache
|
|
82
|
+
* 3. Remote notion-mapping API
|
|
83
|
+
*/
|
|
84
|
+
private resolveKBPathToNotionPage;
|
|
85
|
+
exportDocument(doc: DocumentDetail, listItem: DocumentListItem, dryRun: boolean): Promise<ExportTargetResult>;
|
|
86
|
+
cleanup(): Promise<void>;
|
|
87
|
+
private createNotionPage;
|
|
88
|
+
private updateNotionPage;
|
|
89
|
+
private clearPageContent;
|
|
90
|
+
private appendRemainingBlocks;
|
|
91
|
+
private findExistingNotionPage;
|
|
92
|
+
private recordMapping;
|
|
93
|
+
}
|