@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/client.d.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* API client for Moxn KB
|
|
3
3
|
*/
|
|
4
4
|
import type { ExtractedDocument, MigrationOptions, MigrationResult, DocumentListItem, DocumentDetail, ExportOptions } from './types.js';
|
|
5
|
+
import type { DateFilter } from './date-filter.js';
|
|
5
6
|
export declare class MoxnClient {
|
|
6
7
|
private apiUrl;
|
|
7
8
|
private apiKey;
|
|
@@ -16,7 +17,7 @@ export declare class MoxnClient {
|
|
|
16
17
|
* List all documents, optionally filtered by path prefix.
|
|
17
18
|
* Handles pagination automatically.
|
|
18
19
|
*/
|
|
19
|
-
listDocuments(pathPrefix?: string): Promise<DocumentListItem[]>;
|
|
20
|
+
listDocuments(pathPrefix?: string, dateFilter?: DateFilter): Promise<DocumentListItem[]>;
|
|
20
21
|
/**
|
|
21
22
|
* Get full document detail with sections and content.
|
|
22
23
|
*/
|
|
@@ -79,5 +80,72 @@ export declare class MoxnClient {
|
|
|
79
80
|
* Assign a tag to a document.
|
|
80
81
|
*/
|
|
81
82
|
assignTag(documentId: string, tagId: string, branchId: string): Promise<void>;
|
|
83
|
+
/**
|
|
84
|
+
* List all KB databases for the tenant.
|
|
85
|
+
*/
|
|
86
|
+
listDatabases(): Promise<Array<{
|
|
87
|
+
id: string;
|
|
88
|
+
name: string;
|
|
89
|
+
description: string | null;
|
|
90
|
+
}>>;
|
|
91
|
+
/**
|
|
92
|
+
* Get fully resolved database with columns, options, and document property values.
|
|
93
|
+
*/
|
|
94
|
+
resolveDatabase(databaseId: string): Promise<{
|
|
95
|
+
databaseId: string;
|
|
96
|
+
databaseName: string;
|
|
97
|
+
columns: Array<{
|
|
98
|
+
id: string;
|
|
99
|
+
name: string;
|
|
100
|
+
type: 'select' | 'multi_select';
|
|
101
|
+
position: number;
|
|
102
|
+
newOptionParentPath: string | null;
|
|
103
|
+
options: Array<{
|
|
104
|
+
tagId: string;
|
|
105
|
+
tagName: string;
|
|
106
|
+
tagPath: string;
|
|
107
|
+
tagColor: string | null;
|
|
108
|
+
}>;
|
|
109
|
+
}>;
|
|
110
|
+
documents: Array<{
|
|
111
|
+
documentId: string;
|
|
112
|
+
documentName: string | null;
|
|
113
|
+
documentPath: string | null;
|
|
114
|
+
properties: Record<string, {
|
|
115
|
+
columnId: string;
|
|
116
|
+
columnName: string;
|
|
117
|
+
columnType: 'select' | 'multi_select';
|
|
118
|
+
values: Array<{
|
|
119
|
+
tagId: string;
|
|
120
|
+
tagName: string;
|
|
121
|
+
tagPath: string;
|
|
122
|
+
tagColor: string | null;
|
|
123
|
+
}>;
|
|
124
|
+
}>;
|
|
125
|
+
}>;
|
|
126
|
+
}>;
|
|
127
|
+
/**
|
|
128
|
+
* Get Notion database mapping for a KB database.
|
|
129
|
+
*/
|
|
130
|
+
getNotionDatabaseMapping(kbDatabaseId: string): Promise<{
|
|
131
|
+
mapping: {
|
|
132
|
+
id: string;
|
|
133
|
+
kbDatabaseId: string;
|
|
134
|
+
notionDatabaseId: string;
|
|
135
|
+
notionDatabaseTitle: string | null;
|
|
136
|
+
} | null;
|
|
137
|
+
}>;
|
|
138
|
+
/**
|
|
139
|
+
* Create or upsert a Notion database mapping.
|
|
140
|
+
*/
|
|
141
|
+
createNotionDatabaseMapping(input: {
|
|
142
|
+
kbDatabaseId: string;
|
|
143
|
+
notionDatabaseId: string;
|
|
144
|
+
notionDatabaseTitle?: string;
|
|
145
|
+
}): Promise<{
|
|
146
|
+
mapping: {
|
|
147
|
+
id: string;
|
|
148
|
+
};
|
|
149
|
+
}>;
|
|
82
150
|
private isConflictError;
|
|
83
151
|
}
|
package/dist/client.js
CHANGED
|
@@ -112,7 +112,7 @@ export class MoxnClient {
|
|
|
112
112
|
* List all documents, optionally filtered by path prefix.
|
|
113
113
|
* Handles pagination automatically.
|
|
114
114
|
*/
|
|
115
|
-
async listDocuments(pathPrefix) {
|
|
115
|
+
async listDocuments(pathPrefix, dateFilter) {
|
|
116
116
|
const allDocs = [];
|
|
117
117
|
let offset = 0;
|
|
118
118
|
const limit = 100;
|
|
@@ -124,6 +124,14 @@ export class MoxnClient {
|
|
|
124
124
|
if (pathPrefix) {
|
|
125
125
|
params.set('path', pathPrefix);
|
|
126
126
|
}
|
|
127
|
+
if (dateFilter?.createdAfter)
|
|
128
|
+
params.set('createdAfter', dateFilter.createdAfter);
|
|
129
|
+
if (dateFilter?.createdBefore)
|
|
130
|
+
params.set('createdBefore', dateFilter.createdBefore);
|
|
131
|
+
if (dateFilter?.modifiedAfter)
|
|
132
|
+
params.set('modifiedAfter', dateFilter.modifiedAfter);
|
|
133
|
+
if (dateFilter?.modifiedBefore)
|
|
134
|
+
params.set('modifiedBefore', dateFilter.modifiedBefore);
|
|
127
135
|
const response = await fetch(`${this.apiUrl}/api/v1/kb/documents?${params}`, {
|
|
128
136
|
headers: { 'x-api-key': this.apiKey },
|
|
129
137
|
});
|
|
@@ -393,6 +401,62 @@ export class MoxnClient {
|
|
|
393
401
|
throw new Error(body.error || `Failed to assign tag: ${response.status}`);
|
|
394
402
|
}
|
|
395
403
|
}
|
|
404
|
+
/**
|
|
405
|
+
* List all KB databases for the tenant.
|
|
406
|
+
*/
|
|
407
|
+
async listDatabases() {
|
|
408
|
+
const response = await fetch(`${this.apiUrl}/api/v1/kb/databases`, {
|
|
409
|
+
headers: { 'x-api-key': this.apiKey },
|
|
410
|
+
});
|
|
411
|
+
if (!response.ok) {
|
|
412
|
+
const body = await response.json().catch(() => ({}));
|
|
413
|
+
throw new Error(body.error || `Failed to list databases: ${response.status}`);
|
|
414
|
+
}
|
|
415
|
+
const data = await response.json();
|
|
416
|
+
return data.databases;
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Get fully resolved database with columns, options, and document property values.
|
|
420
|
+
*/
|
|
421
|
+
async resolveDatabase(databaseId) {
|
|
422
|
+
const response = await fetch(`${this.apiUrl}/api/v1/kb/databases/${databaseId}/resolve`, {
|
|
423
|
+
headers: { 'x-api-key': this.apiKey },
|
|
424
|
+
});
|
|
425
|
+
if (!response.ok) {
|
|
426
|
+
const body = await response.json().catch(() => ({}));
|
|
427
|
+
throw new Error(body.error || `Failed to resolve database: ${response.status}`);
|
|
428
|
+
}
|
|
429
|
+
return response.json();
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* Get Notion database mapping for a KB database.
|
|
433
|
+
*/
|
|
434
|
+
async getNotionDatabaseMapping(kbDatabaseId) {
|
|
435
|
+
const response = await fetch(`${this.apiUrl}/api/v1/kb/notion-mappings/databases/by-kb-database/${kbDatabaseId}`, { headers: { 'x-api-key': this.apiKey } });
|
|
436
|
+
if (!response.ok) {
|
|
437
|
+
const body = await response.json().catch(() => ({}));
|
|
438
|
+
throw new Error(body.error || `Failed to get database mapping: ${response.status}`);
|
|
439
|
+
}
|
|
440
|
+
return response.json();
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* Create or upsert a Notion database mapping.
|
|
444
|
+
*/
|
|
445
|
+
async createNotionDatabaseMapping(input) {
|
|
446
|
+
const response = await fetch(`${this.apiUrl}/api/v1/kb/notion-mappings/databases`, {
|
|
447
|
+
method: 'POST',
|
|
448
|
+
headers: {
|
|
449
|
+
'Content-Type': 'application/json',
|
|
450
|
+
'x-api-key': this.apiKey,
|
|
451
|
+
},
|
|
452
|
+
body: JSON.stringify(input),
|
|
453
|
+
});
|
|
454
|
+
if (!response.ok) {
|
|
455
|
+
const body = await response.json().catch(() => ({}));
|
|
456
|
+
throw new Error(body.error || `Failed to create database mapping: ${response.status}`);
|
|
457
|
+
}
|
|
458
|
+
return response.json();
|
|
459
|
+
}
|
|
396
460
|
isConflictError(error) {
|
|
397
461
|
return (error instanceof Error &&
|
|
398
462
|
'documentId' in error &&
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Date filtering utilities for KB migration
|
|
3
|
+
*/
|
|
4
|
+
export interface DateFilter {
|
|
5
|
+
createdAfter?: string;
|
|
6
|
+
createdBefore?: string;
|
|
7
|
+
modifiedAfter?: string;
|
|
8
|
+
modifiedBefore?: string;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Build a DateFilter from CLI option values.
|
|
12
|
+
* Returns undefined if no date options are set.
|
|
13
|
+
*/
|
|
14
|
+
export declare function buildDateFilter(opts: {
|
|
15
|
+
createdAfter?: string;
|
|
16
|
+
createdBefore?: string;
|
|
17
|
+
modifiedAfter?: string;
|
|
18
|
+
modifiedBefore?: string;
|
|
19
|
+
}): DateFilter | undefined;
|
|
20
|
+
/**
|
|
21
|
+
* Check if an item matches the date filter.
|
|
22
|
+
*
|
|
23
|
+
* Filter behavior with null modifiedAt:
|
|
24
|
+
* - modifiedAfter: Excludes null (never modified is not "modified after X")
|
|
25
|
+
* - modifiedBefore: Excludes null (no modification event to compare)
|
|
26
|
+
* - createdAfter + modifiedAfter: Union — include if created after OR modified after
|
|
27
|
+
*/
|
|
28
|
+
export declare function matchesDateFilter(filter: DateFilter | undefined, item: {
|
|
29
|
+
createdAt?: string | Date | null;
|
|
30
|
+
modifiedAt?: string | Date | null;
|
|
31
|
+
}): boolean;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Date filtering utilities for KB migration
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* Build a DateFilter from CLI option values.
|
|
6
|
+
* Returns undefined if no date options are set.
|
|
7
|
+
*/
|
|
8
|
+
export function buildDateFilter(opts) {
|
|
9
|
+
const filter = {};
|
|
10
|
+
if (opts.createdAfter)
|
|
11
|
+
filter.createdAfter = opts.createdAfter;
|
|
12
|
+
if (opts.createdBefore)
|
|
13
|
+
filter.createdBefore = opts.createdBefore;
|
|
14
|
+
if (opts.modifiedAfter)
|
|
15
|
+
filter.modifiedAfter = opts.modifiedAfter;
|
|
16
|
+
if (opts.modifiedBefore)
|
|
17
|
+
filter.modifiedBefore = opts.modifiedBefore;
|
|
18
|
+
return Object.keys(filter).length > 0 ? filter : undefined;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Check if an item matches the date filter.
|
|
22
|
+
*
|
|
23
|
+
* Filter behavior with null modifiedAt:
|
|
24
|
+
* - modifiedAfter: Excludes null (never modified is not "modified after X")
|
|
25
|
+
* - modifiedBefore: Excludes null (no modification event to compare)
|
|
26
|
+
* - createdAfter + modifiedAfter: Union — include if created after OR modified after
|
|
27
|
+
*/
|
|
28
|
+
export function matchesDateFilter(filter, item) {
|
|
29
|
+
if (!filter)
|
|
30
|
+
return true;
|
|
31
|
+
const createdAt = item.createdAt ? new Date(item.createdAt).getTime() : null;
|
|
32
|
+
const modifiedAt = item.modifiedAt ? new Date(item.modifiedAt).getTime() : null;
|
|
33
|
+
// When both created and modified "after" are set, use union logic
|
|
34
|
+
const hasCreatedAfter = !!filter.createdAfter;
|
|
35
|
+
const hasModifiedAfter = !!filter.modifiedAfter;
|
|
36
|
+
const useUnionForAfter = hasCreatedAfter && hasModifiedAfter;
|
|
37
|
+
let createdAfterMatch = true;
|
|
38
|
+
let modifiedAfterMatch = true;
|
|
39
|
+
if (filter.createdAfter && createdAt !== null) {
|
|
40
|
+
createdAfterMatch = createdAt >= new Date(filter.createdAfter).getTime();
|
|
41
|
+
}
|
|
42
|
+
else if (filter.createdAfter && createdAt === null) {
|
|
43
|
+
createdAfterMatch = false;
|
|
44
|
+
}
|
|
45
|
+
if (filter.modifiedAfter) {
|
|
46
|
+
if (modifiedAt !== null) {
|
|
47
|
+
modifiedAfterMatch = modifiedAt >= new Date(filter.modifiedAfter).getTime();
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
modifiedAfterMatch = false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
// Union: pass if either matches
|
|
54
|
+
if (useUnionForAfter) {
|
|
55
|
+
if (!createdAfterMatch && !modifiedAfterMatch)
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
// Individual: each must pass
|
|
60
|
+
if (hasCreatedAfter && !createdAfterMatch)
|
|
61
|
+
return false;
|
|
62
|
+
if (hasModifiedAfter && !modifiedAfterMatch)
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
// "Before" filters are always AND (restrictive)
|
|
66
|
+
if (filter.createdBefore && createdAt !== null) {
|
|
67
|
+
if (createdAt > new Date(filter.createdBefore).getTime())
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
else if (filter.createdBefore && createdAt === null) {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
if (filter.modifiedBefore) {
|
|
74
|
+
if (modifiedAt !== null) {
|
|
75
|
+
if (modifiedAt > new Date(filter.modifiedBefore).getTime())
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
return false; // null modifiedAt excluded from modifiedBefore filter
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Export runner for Moxn KB -> Notion
|
|
3
|
+
*
|
|
4
|
+
* Fetches documents from the KB API and exports them to Notion pages
|
|
5
|
+
* using the NotionExportTarget with a two-pass algorithm:
|
|
6
|
+
*
|
|
7
|
+
* Pass 1: Create/update all Notion pages (content without cross-references)
|
|
8
|
+
* Pass 2: Resolve KB path references to Notion page IDs and append link blocks
|
|
9
|
+
*/
|
|
10
|
+
import type { ExportTargetLog } from './targets/base.js';
|
|
11
|
+
import type { DateFilter } from './date-filter.js';
|
|
12
|
+
export interface ExportNotionOptions {
|
|
13
|
+
apiUrl: string;
|
|
14
|
+
apiKey: string;
|
|
15
|
+
notionToken: string;
|
|
16
|
+
parentPageId: string;
|
|
17
|
+
basePath: string;
|
|
18
|
+
conflictStrategy: 'skip' | 'update';
|
|
19
|
+
dryRun: boolean;
|
|
20
|
+
dateFilter?: DateFilter;
|
|
21
|
+
}
|
|
22
|
+
export declare function runNotionExport(options: ExportNotionOptions): Promise<ExportTargetLog>;
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Export runner for Moxn KB -> Notion
|
|
3
|
+
*
|
|
4
|
+
* Fetches documents from the KB API and exports them to Notion pages
|
|
5
|
+
* using the NotionExportTarget with a two-pass algorithm:
|
|
6
|
+
*
|
|
7
|
+
* Pass 1: Create/update all Notion pages (content without cross-references)
|
|
8
|
+
* Pass 2: Resolve KB path references to Notion page IDs and append link blocks
|
|
9
|
+
*/
|
|
10
|
+
import { Client } from '@notionhq/client';
|
|
11
|
+
import { MoxnClient } from './client.js';
|
|
12
|
+
import { matchesDateFilter } from './date-filter.js';
|
|
13
|
+
import { NotionExportTarget } from './targets/notion.js';
|
|
14
|
+
import { exportDatabase } from './targets/notion-database-export.js';
|
|
15
|
+
export async function runNotionExport(options) {
|
|
16
|
+
const startTime = Date.now();
|
|
17
|
+
// Create Moxn client for reading docs
|
|
18
|
+
const client = new MoxnClient({
|
|
19
|
+
apiUrl: options.apiUrl,
|
|
20
|
+
apiKey: options.apiKey,
|
|
21
|
+
basePath: options.basePath,
|
|
22
|
+
imageDir: 'images',
|
|
23
|
+
pdfDir: 'pdfs',
|
|
24
|
+
csvDir: 'csvs',
|
|
25
|
+
dryRun: options.dryRun,
|
|
26
|
+
});
|
|
27
|
+
// Create Notion export target
|
|
28
|
+
const target = new NotionExportTarget({
|
|
29
|
+
notionToken: options.notionToken,
|
|
30
|
+
parentPageId: options.parentPageId,
|
|
31
|
+
conflictStrategy: options.conflictStrategy,
|
|
32
|
+
apiUrl: options.apiUrl,
|
|
33
|
+
apiKey: options.apiKey,
|
|
34
|
+
});
|
|
35
|
+
// Validate target
|
|
36
|
+
await target.validate();
|
|
37
|
+
// List documents
|
|
38
|
+
console.log('Fetching document list...');
|
|
39
|
+
const allDocuments = await client.listDocuments(options.basePath || undefined, options.dateFilter);
|
|
40
|
+
// Apply client-side date filtering
|
|
41
|
+
let documents = allDocuments;
|
|
42
|
+
if (options.dateFilter) {
|
|
43
|
+
documents = allDocuments.filter((doc) => matchesDateFilter(options.dateFilter, {
|
|
44
|
+
createdAt: doc.createdAt,
|
|
45
|
+
modifiedAt: doc.updatedAt,
|
|
46
|
+
}));
|
|
47
|
+
const skipped = allDocuments.length - documents.length;
|
|
48
|
+
if (skipped > 0) {
|
|
49
|
+
console.log(`Filtered ${skipped} documents by date criteria`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
console.log(`Found ${documents.length} documents to export`);
|
|
53
|
+
if (documents.length === 0) {
|
|
54
|
+
return buildEmptyLog(options, startTime);
|
|
55
|
+
}
|
|
56
|
+
// ============================================
|
|
57
|
+
// Pre-fetch all documents for two-pass export
|
|
58
|
+
// ============================================
|
|
59
|
+
console.log('Fetching document details...');
|
|
60
|
+
const docDetails = [];
|
|
61
|
+
for (let i = 0; i < documents.length; i++) {
|
|
62
|
+
const docItem = documents[i];
|
|
63
|
+
try {
|
|
64
|
+
const doc = await client.getDocument(docItem.id);
|
|
65
|
+
docDetails.push({ listItem: docItem, detail: doc });
|
|
66
|
+
// Register the document path -> ID mapping for reference resolution
|
|
67
|
+
target.registerDocument(doc);
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
71
|
+
console.log(` Warning: Failed to fetch ${docItem.path}: ${message}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// ============================================
|
|
75
|
+
// Pass 1: Create/update Notion pages
|
|
76
|
+
// ============================================
|
|
77
|
+
console.log(`\nPass 1: Exporting ${docDetails.length} documents to Notion...`);
|
|
78
|
+
const results = [];
|
|
79
|
+
for (let i = 0; i < docDetails.length; i++) {
|
|
80
|
+
const { listItem: docItem, detail: doc } = docDetails[i];
|
|
81
|
+
const progress = `(${i + 1}/${docDetails.length})`;
|
|
82
|
+
console.log(`Processing: ${docItem.path} ${progress}`);
|
|
83
|
+
try {
|
|
84
|
+
const result = await target.exportDocument(doc, docItem, options.dryRun);
|
|
85
|
+
results.push(result);
|
|
86
|
+
const statusIcon = {
|
|
87
|
+
created: '\u2713',
|
|
88
|
+
updated: '\u21bb',
|
|
89
|
+
skipped: '-',
|
|
90
|
+
failed: '\u2717',
|
|
91
|
+
}[result.status];
|
|
92
|
+
const extIdStr = result.externalId ? ` [${result.externalId}]` : '';
|
|
93
|
+
console.log(` ${statusIcon} ${result.status}: ${result.documentPath}${extIdStr}`);
|
|
94
|
+
if (result.error) {
|
|
95
|
+
console.log(` Error: ${result.error}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
100
|
+
console.log(` \u2717 failed: ${docItem.path}`);
|
|
101
|
+
console.log(` Error: ${message}`);
|
|
102
|
+
results.push({
|
|
103
|
+
documentId: docItem.id,
|
|
104
|
+
documentPath: docItem.path,
|
|
105
|
+
status: 'failed',
|
|
106
|
+
error: message,
|
|
107
|
+
duration: 0,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// ============================================
|
|
112
|
+
// Pass 1.5: Export databases
|
|
113
|
+
// ============================================
|
|
114
|
+
if (!options.dryRun) {
|
|
115
|
+
// Collect unique database IDs from exported documents
|
|
116
|
+
const databaseIdsToExport = new Set();
|
|
117
|
+
for (const { detail: doc } of docDetails) {
|
|
118
|
+
for (const section of doc.sections) {
|
|
119
|
+
for (const block of section.content) {
|
|
120
|
+
if (block.blockType === 'database_embed' && block.databaseId) {
|
|
121
|
+
databaseIdsToExport.add(block.databaseId);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (databaseIdsToExport.size > 0) {
|
|
127
|
+
console.log(`\nPass 1.5: Exporting ${databaseIdsToExport.size} databases to Notion...`);
|
|
128
|
+
const dbCtx = {
|
|
129
|
+
notionClient: new Client({ auth: options.notionToken }),
|
|
130
|
+
moxnClient: client,
|
|
131
|
+
conflictStrategy: options.conflictStrategy,
|
|
132
|
+
dryRun: options.dryRun,
|
|
133
|
+
};
|
|
134
|
+
let dbCreated = 0, dbUpdated = 0, dbSkipped = 0, dbFailed = 0;
|
|
135
|
+
for (const dbId of databaseIdsToExport) {
|
|
136
|
+
try {
|
|
137
|
+
// Use the parent page ID as the target for database creation
|
|
138
|
+
const parentPageId = options.parentPageId;
|
|
139
|
+
const result = await exportDatabase(dbCtx, dbId, parentPageId);
|
|
140
|
+
const icon = { created: '\u2713', updated: '\u21bb', skipped: '-', failed: '\u2717' }[result.status];
|
|
141
|
+
console.log(` ${icon} ${result.status}: ${result.kbDatabaseName} (${result.entriesCreated} entries)`);
|
|
142
|
+
if (result.error)
|
|
143
|
+
console.log(` Error: ${result.error}`);
|
|
144
|
+
if (result.status === 'created')
|
|
145
|
+
dbCreated++;
|
|
146
|
+
else if (result.status === 'updated')
|
|
147
|
+
dbUpdated++;
|
|
148
|
+
else if (result.status === 'skipped')
|
|
149
|
+
dbSkipped++;
|
|
150
|
+
else if (result.status === 'failed')
|
|
151
|
+
dbFailed++;
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
155
|
+
console.log(` \u2717 failed: database ${dbId}`);
|
|
156
|
+
console.log(` Error: ${msg}`);
|
|
157
|
+
dbFailed++;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
console.log(`\nDatabases: ${dbCreated} created, ${dbUpdated} updated, ${dbSkipped} skipped, ${dbFailed} failed`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
// ============================================
|
|
164
|
+
// Pass 2: Resolve cross-references
|
|
165
|
+
// ============================================
|
|
166
|
+
if (!options.dryRun) {
|
|
167
|
+
// Find documents that have references and were successfully exported
|
|
168
|
+
const successfulExports = results.filter((r) => (r.status === 'created' || r.status === 'updated' || r.status === 'skipped') && r.externalId);
|
|
169
|
+
if (successfulExports.length > 0) {
|
|
170
|
+
console.log(`\nPass 2: Resolving cross-references for ${successfulExports.length} documents...`);
|
|
171
|
+
let totalResolved = 0;
|
|
172
|
+
let totalUnresolved = 0;
|
|
173
|
+
for (const result of successfulExports) {
|
|
174
|
+
const docDetail = docDetails.find((d) => d.detail.id === result.documentId);
|
|
175
|
+
if (!docDetail || !result.externalId)
|
|
176
|
+
continue;
|
|
177
|
+
try {
|
|
178
|
+
const { resolved, unresolved } = await target.resolveAndAppendReferences(docDetail.detail, result.externalId);
|
|
179
|
+
if (resolved > 0 || unresolved > 0) {
|
|
180
|
+
console.log(` ${docDetail.detail.path}: ${resolved} resolved, ${unresolved} unresolved`);
|
|
181
|
+
}
|
|
182
|
+
totalResolved += resolved;
|
|
183
|
+
totalUnresolved += unresolved;
|
|
184
|
+
}
|
|
185
|
+
catch (error) {
|
|
186
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
187
|
+
console.log(` Warning: Reference resolution failed for ${docDetail.detail.path}: ${message}`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (totalResolved > 0 || totalUnresolved > 0) {
|
|
191
|
+
console.log(`\nReferences: ${totalResolved} resolved, ${totalUnresolved} unresolved`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
await target.cleanup();
|
|
196
|
+
return {
|
|
197
|
+
timestamp: new Date().toISOString(),
|
|
198
|
+
sourceApi: options.apiUrl,
|
|
199
|
+
target: {
|
|
200
|
+
type: target.targetType,
|
|
201
|
+
location: target.targetLocation,
|
|
202
|
+
},
|
|
203
|
+
basePath: options.basePath || '/',
|
|
204
|
+
options: {
|
|
205
|
+
dryRun: options.dryRun,
|
|
206
|
+
conflictStrategy: options.conflictStrategy,
|
|
207
|
+
},
|
|
208
|
+
results,
|
|
209
|
+
summary: {
|
|
210
|
+
total: results.length,
|
|
211
|
+
created: results.filter((r) => r.status === 'created').length,
|
|
212
|
+
updated: results.filter((r) => r.status === 'updated').length,
|
|
213
|
+
skipped: results.filter((r) => r.status === 'skipped').length,
|
|
214
|
+
failed: results.filter((r) => r.status === 'failed').length,
|
|
215
|
+
duration: Date.now() - startTime,
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
function buildEmptyLog(options, startTime) {
|
|
220
|
+
return {
|
|
221
|
+
timestamp: new Date().toISOString(),
|
|
222
|
+
sourceApi: options.apiUrl,
|
|
223
|
+
target: {
|
|
224
|
+
type: 'notion',
|
|
225
|
+
location: `Notion (parent: ${options.parentPageId})`,
|
|
226
|
+
},
|
|
227
|
+
basePath: options.basePath || '/',
|
|
228
|
+
options: {
|
|
229
|
+
dryRun: options.dryRun,
|
|
230
|
+
conflictStrategy: options.conflictStrategy,
|
|
231
|
+
},
|
|
232
|
+
results: [],
|
|
233
|
+
summary: {
|
|
234
|
+
total: 0,
|
|
235
|
+
created: 0,
|
|
236
|
+
updated: 0,
|
|
237
|
+
skipped: 0,
|
|
238
|
+
failed: 0,
|
|
239
|
+
duration: Date.now() - startTime,
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
}
|
package/dist/export.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import * as fs from 'fs';
|
|
8
8
|
import * as path from 'path';
|
|
9
9
|
import { MoxnClient } from './client.js';
|
|
10
|
+
import { matchesDateFilter } from './date-filter.js';
|
|
10
11
|
// ──────────────────────────────────────────────
|
|
11
12
|
// Utility functions
|
|
12
13
|
// ──────────────────────────────────────────────
|
|
@@ -141,7 +142,19 @@ export async function runExport(outputDir, options) {
|
|
|
141
142
|
const client = new MoxnClient(options);
|
|
142
143
|
// List documents
|
|
143
144
|
console.error('Fetching document list...');
|
|
144
|
-
const
|
|
145
|
+
const allDocuments = await client.listDocuments(options.basePath || undefined, options.dateFilter);
|
|
146
|
+
// Apply client-side date filtering as well
|
|
147
|
+
let documents = allDocuments;
|
|
148
|
+
if (options.dateFilter) {
|
|
149
|
+
documents = allDocuments.filter((doc) => matchesDateFilter(options.dateFilter, {
|
|
150
|
+
createdAt: doc.createdAt,
|
|
151
|
+
modifiedAt: doc.updatedAt,
|
|
152
|
+
}));
|
|
153
|
+
const skipped = allDocuments.length - documents.length;
|
|
154
|
+
if (skipped > 0) {
|
|
155
|
+
console.error(`Filtered ${skipped} documents by date criteria`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
145
158
|
console.error(`Found ${documents.length} documents`);
|
|
146
159
|
if (documents.length === 0) {
|
|
147
160
|
return {
|