@moxn/kb-migrate 0.4.3 → 0.4.7
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 +14 -1
- package/dist/client.js +27 -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 +188 -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 +165 -61
- 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 +5 -0
- package/dist/targets/index.js +5 -0
- package/dist/targets/notion.d.ts +93 -0
- package/dist/targets/notion.js +478 -0
- package/dist/types.d.ts +18 -2
- package/package.json +23 -1
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,478 @@
|
|
|
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 { Client } from '@notionhq/client';
|
|
15
|
+
import { markdownToBlocks } from '@tryfabric/martian';
|
|
16
|
+
import { ExportTarget, } from './base.js';
|
|
17
|
+
// ============================================
|
|
18
|
+
// Helpers
|
|
19
|
+
// ============================================
|
|
20
|
+
const RATE_LIMIT_MS = 350;
|
|
21
|
+
function sleep(ms) {
|
|
22
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
23
|
+
}
|
|
24
|
+
/** Strip <moxn:comment> tags from text, preserving the inner text. */
|
|
25
|
+
function stripCommentTags(text) {
|
|
26
|
+
return text.replace(/<moxn:comment[^>]*>([\s\S]*?)<\/moxn:comment>/g, '$1');
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Extract KB path references from markdown text.
|
|
30
|
+
* Matches markdown links where the URL starts with / (KB internal paths).
|
|
31
|
+
* Returns the extracted references and the text with references removed.
|
|
32
|
+
*/
|
|
33
|
+
function extractKBPathReferences(text) {
|
|
34
|
+
const references = [];
|
|
35
|
+
// Match [display text](/kb/path) - links starting with /
|
|
36
|
+
const KB_PATH_RE = /\[([^\]]+)\]\((\/[^)]+)\)/g;
|
|
37
|
+
const cleanedText = text.replace(KB_PATH_RE, (match, displayText, path) => {
|
|
38
|
+
// Only treat as KB reference if it looks like a KB document path
|
|
39
|
+
// (starts with / and doesn't look like an external URL fragment)
|
|
40
|
+
if (path.startsWith('/') && !path.startsWith('//')) {
|
|
41
|
+
references.push({ targetKbPath: path, displayText });
|
|
42
|
+
// Replace with plain text (the link text) since we'll add references separately
|
|
43
|
+
return displayText;
|
|
44
|
+
}
|
|
45
|
+
return match;
|
|
46
|
+
});
|
|
47
|
+
return { cleanedText, references };
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Convert a KB document's sections into a single markdown string.
|
|
51
|
+
* Section names become H2 headings (mirrors the import convention).
|
|
52
|
+
*
|
|
53
|
+
* If extractReferences is true, also extracts KB path references from
|
|
54
|
+
* the content and returns them separately (for two-pass export).
|
|
55
|
+
*/
|
|
56
|
+
function sectionsToMarkdown(sections, options) {
|
|
57
|
+
const parts = [];
|
|
58
|
+
const allReferences = [];
|
|
59
|
+
const extractRefs = options?.extractReferences ?? false;
|
|
60
|
+
for (const section of sections) {
|
|
61
|
+
parts.push(`## ${section.name}\n`);
|
|
62
|
+
for (const block of section.content) {
|
|
63
|
+
if (block.blockType === 'text' && block.text) {
|
|
64
|
+
let text = stripCommentTags(block.text);
|
|
65
|
+
if (extractRefs) {
|
|
66
|
+
const { cleanedText, references } = extractKBPathReferences(text);
|
|
67
|
+
text = cleanedText;
|
|
68
|
+
allReferences.push(...references);
|
|
69
|
+
}
|
|
70
|
+
parts.push(text);
|
|
71
|
+
parts.push('');
|
|
72
|
+
}
|
|
73
|
+
else if (block.blockType === 'image' && block.url) {
|
|
74
|
+
parts.push(``);
|
|
75
|
+
parts.push('');
|
|
76
|
+
}
|
|
77
|
+
else if (block.blockType === 'document' && block.url) {
|
|
78
|
+
parts.push(`[${block.filename || 'document'}](${block.url})`);
|
|
79
|
+
parts.push('');
|
|
80
|
+
}
|
|
81
|
+
else if (block.blockType === 'csv' && block.url) {
|
|
82
|
+
parts.push(`[${block.filename || 'data.csv'}](${block.url})`);
|
|
83
|
+
parts.push('');
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return { markdown: parts.join('\n').trim(), references: allReferences };
|
|
88
|
+
}
|
|
89
|
+
// Max 100 blocks per API call
|
|
90
|
+
const MAX_BLOCKS_PER_APPEND = 100;
|
|
91
|
+
// ============================================
|
|
92
|
+
// Target
|
|
93
|
+
// ============================================
|
|
94
|
+
export class NotionExportTarget extends ExportTarget {
|
|
95
|
+
client;
|
|
96
|
+
/** Cache: kbDocumentId -> notionPageId (from mapping API) */
|
|
97
|
+
mappingCache = new Map();
|
|
98
|
+
/** Cache: notionPageId -> true (pages we've verified exist) */
|
|
99
|
+
verifiedPages = new Set();
|
|
100
|
+
/** Map from KB path prefix -> Notion page ID (for nested page creation) */
|
|
101
|
+
pathToNotionId = new Map();
|
|
102
|
+
/** KB path -> KB document ID mapping (built during two-pass export) */
|
|
103
|
+
kbPathToDocId = new Map();
|
|
104
|
+
/** KB doc ID -> Notion page ID mapping (built during pass 1) */
|
|
105
|
+
docIdToNotionPageId = new Map();
|
|
106
|
+
constructor(config) {
|
|
107
|
+
super(config);
|
|
108
|
+
this.client = new Client({ auth: config.notionToken });
|
|
109
|
+
}
|
|
110
|
+
get targetType() {
|
|
111
|
+
return 'notion';
|
|
112
|
+
}
|
|
113
|
+
get targetLocation() {
|
|
114
|
+
return `Notion (parent: ${this.config.parentPageId})`;
|
|
115
|
+
}
|
|
116
|
+
// ============================================
|
|
117
|
+
// Validation
|
|
118
|
+
// ============================================
|
|
119
|
+
async validate() {
|
|
120
|
+
console.log('Validating Notion API token...');
|
|
121
|
+
try {
|
|
122
|
+
await this.client.users.me({});
|
|
123
|
+
console.log(' Token valid.');
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
throw new Error(`Notion token invalid: ${error instanceof Error ? error.message : error}`);
|
|
127
|
+
}
|
|
128
|
+
console.log('Verifying parent page...');
|
|
129
|
+
try {
|
|
130
|
+
await sleep(RATE_LIMIT_MS);
|
|
131
|
+
await this.client.pages.retrieve({ page_id: this.config.parentPageId });
|
|
132
|
+
console.log(' Parent page accessible.');
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
throw new Error(`Parent page not accessible: ${error instanceof Error ? error.message : error}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
// ============================================
|
|
139
|
+
// Two-Pass Export Support
|
|
140
|
+
// ============================================
|
|
141
|
+
/**
|
|
142
|
+
* Register a KB document in the path -> ID index.
|
|
143
|
+
* Called before pass 1 so references can be resolved in pass 2.
|
|
144
|
+
*/
|
|
145
|
+
registerDocument(doc) {
|
|
146
|
+
this.kbPathToDocId.set(doc.path, doc.id);
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* After pass 1, record a successful export mapping.
|
|
150
|
+
* Called by the runner when a doc is created/updated successfully.
|
|
151
|
+
*/
|
|
152
|
+
registerExportedPage(kbDocumentId, notionPageId) {
|
|
153
|
+
this.docIdToNotionPageId.set(kbDocumentId, notionPageId);
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Pass 2: Resolve references for a document and append link blocks to its Notion page.
|
|
157
|
+
*
|
|
158
|
+
* Finds KB path links in the document content, resolves them to Notion page IDs
|
|
159
|
+
* (from the current export batch or pre-existing mappings), and appends
|
|
160
|
+
* mention/link blocks to the Notion page.
|
|
161
|
+
*
|
|
162
|
+
* Returns the number of references resolved.
|
|
163
|
+
*/
|
|
164
|
+
async resolveAndAppendReferences(doc, notionPageId) {
|
|
165
|
+
// Extract references from section content
|
|
166
|
+
const { references } = sectionsToMarkdown(doc.sections, { extractReferences: true });
|
|
167
|
+
if (references.length === 0) {
|
|
168
|
+
return { resolved: 0, unresolved: 0 };
|
|
169
|
+
}
|
|
170
|
+
// Deduplicate references by target path
|
|
171
|
+
const seen = new Set();
|
|
172
|
+
const uniqueRefs = references.filter((ref) => {
|
|
173
|
+
if (seen.has(ref.targetKbPath))
|
|
174
|
+
return false;
|
|
175
|
+
seen.add(ref.targetKbPath);
|
|
176
|
+
return true;
|
|
177
|
+
});
|
|
178
|
+
const referenceBlocks = [];
|
|
179
|
+
let resolved = 0;
|
|
180
|
+
let unresolved = 0;
|
|
181
|
+
for (const ref of uniqueRefs) {
|
|
182
|
+
const targetNotionPageId = await this.resolveKBPathToNotionPage(ref.targetKbPath);
|
|
183
|
+
if (targetNotionPageId) {
|
|
184
|
+
// Create a mention block linking to the Notion page
|
|
185
|
+
referenceBlocks.push({
|
|
186
|
+
object: 'block',
|
|
187
|
+
type: 'paragraph',
|
|
188
|
+
paragraph: {
|
|
189
|
+
rich_text: [
|
|
190
|
+
{ type: 'text', text: { content: 'See also: ' } },
|
|
191
|
+
{
|
|
192
|
+
type: 'mention',
|
|
193
|
+
mention: {
|
|
194
|
+
type: 'page',
|
|
195
|
+
page: { id: targetNotionPageId },
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
],
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
resolved++;
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
// Graceful degradation: plain text reference
|
|
205
|
+
referenceBlocks.push({
|
|
206
|
+
object: 'block',
|
|
207
|
+
type: 'paragraph',
|
|
208
|
+
paragraph: {
|
|
209
|
+
rich_text: [
|
|
210
|
+
{
|
|
211
|
+
type: 'text',
|
|
212
|
+
text: { content: `See also: ${ref.displayText} (${ref.targetKbPath})` },
|
|
213
|
+
annotations: { italic: true, color: 'gray' },
|
|
214
|
+
},
|
|
215
|
+
],
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
unresolved++;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (referenceBlocks.length > 0) {
|
|
222
|
+
// Add a divider before references section
|
|
223
|
+
const blocksToAppend = [
|
|
224
|
+
{ object: 'block', type: 'divider', divider: {} },
|
|
225
|
+
{
|
|
226
|
+
object: 'block',
|
|
227
|
+
type: 'heading_3',
|
|
228
|
+
heading_3: {
|
|
229
|
+
rich_text: [{ type: 'text', text: { content: 'References' } }],
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
...referenceBlocks,
|
|
233
|
+
];
|
|
234
|
+
await this.appendRemainingBlocks(notionPageId, blocksToAppend);
|
|
235
|
+
}
|
|
236
|
+
return { resolved, unresolved };
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Resolve a KB path to a Notion page ID.
|
|
240
|
+
*
|
|
241
|
+
* Checks three sources in order:
|
|
242
|
+
* 1. Current export batch (docIdToNotionPageId from pass 1)
|
|
243
|
+
* 2. Local mapping cache
|
|
244
|
+
* 3. Remote notion-mapping API
|
|
245
|
+
*/
|
|
246
|
+
async resolveKBPathToNotionPage(kbPath) {
|
|
247
|
+
// 1. Try to find the KB doc ID from path
|
|
248
|
+
const kbDocId = this.kbPathToDocId.get(kbPath);
|
|
249
|
+
if (kbDocId) {
|
|
250
|
+
// 2. Check if it was exported in this batch
|
|
251
|
+
const batchNotionId = this.docIdToNotionPageId.get(kbDocId);
|
|
252
|
+
if (batchNotionId)
|
|
253
|
+
return batchNotionId;
|
|
254
|
+
// 3. Check mapping cache / API
|
|
255
|
+
const mappedNotionId = await this.findExistingNotionPage(kbDocId);
|
|
256
|
+
if (mappedNotionId)
|
|
257
|
+
return mappedNotionId;
|
|
258
|
+
}
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
// ============================================
|
|
262
|
+
// Export (Pass 1)
|
|
263
|
+
// ============================================
|
|
264
|
+
async exportDocument(doc, listItem, dryRun) {
|
|
265
|
+
const startTime = Date.now();
|
|
266
|
+
try {
|
|
267
|
+
const existingNotionPageId = await this.findExistingNotionPage(doc.id);
|
|
268
|
+
if (existingNotionPageId) {
|
|
269
|
+
if (this.config.conflictStrategy === 'skip') {
|
|
270
|
+
this.registerExportedPage(doc.id, existingNotionPageId);
|
|
271
|
+
return {
|
|
272
|
+
documentId: doc.id,
|
|
273
|
+
documentPath: doc.path,
|
|
274
|
+
status: 'skipped',
|
|
275
|
+
externalId: existingNotionPageId,
|
|
276
|
+
duration: Date.now() - startTime,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
if (dryRun) {
|
|
280
|
+
return {
|
|
281
|
+
documentId: doc.id,
|
|
282
|
+
documentPath: doc.path,
|
|
283
|
+
status: 'updated',
|
|
284
|
+
externalId: existingNotionPageId,
|
|
285
|
+
duration: Date.now() - startTime,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
await this.updateNotionPage(existingNotionPageId, doc);
|
|
289
|
+
await this.recordMapping(doc.id, existingNotionPageId);
|
|
290
|
+
this.registerExportedPage(doc.id, existingNotionPageId);
|
|
291
|
+
return {
|
|
292
|
+
documentId: doc.id,
|
|
293
|
+
documentPath: doc.path,
|
|
294
|
+
status: 'updated',
|
|
295
|
+
externalId: existingNotionPageId,
|
|
296
|
+
duration: Date.now() - startTime,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
if (dryRun) {
|
|
300
|
+
return {
|
|
301
|
+
documentId: doc.id,
|
|
302
|
+
documentPath: doc.path,
|
|
303
|
+
status: 'created',
|
|
304
|
+
duration: Date.now() - startTime,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
const notionPageId = await this.createNotionPage(doc);
|
|
308
|
+
await this.recordMapping(doc.id, notionPageId);
|
|
309
|
+
this.registerExportedPage(doc.id, notionPageId);
|
|
310
|
+
return {
|
|
311
|
+
documentId: doc.id,
|
|
312
|
+
documentPath: doc.path,
|
|
313
|
+
status: 'created',
|
|
314
|
+
externalId: notionPageId,
|
|
315
|
+
duration: Date.now() - startTime,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
catch (error) {
|
|
319
|
+
console.error(`Export failed for ${doc.path}: ${error instanceof Error ? error.message : error}`);
|
|
320
|
+
return {
|
|
321
|
+
documentId: doc.id,
|
|
322
|
+
documentPath: doc.path,
|
|
323
|
+
status: 'failed',
|
|
324
|
+
error: error instanceof Error ? error.message : String(error),
|
|
325
|
+
duration: Date.now() - startTime,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
async cleanup() {
|
|
330
|
+
// No-op
|
|
331
|
+
}
|
|
332
|
+
// ============================================
|
|
333
|
+
// Notion page creation / update
|
|
334
|
+
// ============================================
|
|
335
|
+
async createNotionPage(doc) {
|
|
336
|
+
const { markdown } = sectionsToMarkdown(doc.sections, { extractReferences: true });
|
|
337
|
+
const blocks = markdownToBlocks(markdown);
|
|
338
|
+
// First batch: up to 100 blocks as children of the new page
|
|
339
|
+
const firstBatch = blocks.slice(0, MAX_BLOCKS_PER_APPEND);
|
|
340
|
+
const remainingBlocks = blocks.slice(MAX_BLOCKS_PER_APPEND);
|
|
341
|
+
await sleep(RATE_LIMIT_MS);
|
|
342
|
+
const response = await this.client.pages.create({
|
|
343
|
+
parent: { page_id: this.config.parentPageId },
|
|
344
|
+
properties: {
|
|
345
|
+
title: {
|
|
346
|
+
type: 'title',
|
|
347
|
+
title: [{ type: 'text', text: { content: doc.name } }],
|
|
348
|
+
},
|
|
349
|
+
},
|
|
350
|
+
children: firstBatch,
|
|
351
|
+
});
|
|
352
|
+
const pageId = response.id;
|
|
353
|
+
// Append remaining blocks in batches
|
|
354
|
+
await this.appendRemainingBlocks(pageId, remainingBlocks);
|
|
355
|
+
return pageId;
|
|
356
|
+
}
|
|
357
|
+
async updateNotionPage(notionPageId, doc) {
|
|
358
|
+
await sleep(RATE_LIMIT_MS);
|
|
359
|
+
await this.client.pages.update({
|
|
360
|
+
page_id: notionPageId,
|
|
361
|
+
properties: {
|
|
362
|
+
title: {
|
|
363
|
+
type: 'title',
|
|
364
|
+
title: [{ type: 'text', text: { content: doc.name } }],
|
|
365
|
+
},
|
|
366
|
+
},
|
|
367
|
+
});
|
|
368
|
+
await this.clearPageContent(notionPageId);
|
|
369
|
+
const { markdown } = sectionsToMarkdown(doc.sections, { extractReferences: true });
|
|
370
|
+
const blocks = markdownToBlocks(markdown);
|
|
371
|
+
await this.appendRemainingBlocks(notionPageId, blocks);
|
|
372
|
+
}
|
|
373
|
+
async clearPageContent(pageId) {
|
|
374
|
+
await sleep(RATE_LIMIT_MS);
|
|
375
|
+
const children = await this.client.blocks.children.list({
|
|
376
|
+
block_id: pageId,
|
|
377
|
+
page_size: 100,
|
|
378
|
+
});
|
|
379
|
+
for (const block of children.results) {
|
|
380
|
+
try {
|
|
381
|
+
await sleep(RATE_LIMIT_MS);
|
|
382
|
+
const deletePromise = this.client.blocks.delete({ block_id: block.id });
|
|
383
|
+
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('Block delete timeout')), 10000));
|
|
384
|
+
await Promise.race([deletePromise, timeoutPromise]);
|
|
385
|
+
}
|
|
386
|
+
catch {
|
|
387
|
+
// Continue with other blocks even if one fails
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
// Handle pagination
|
|
391
|
+
if (children.has_more && children.next_cursor) {
|
|
392
|
+
let cursor = children.next_cursor;
|
|
393
|
+
while (cursor) {
|
|
394
|
+
try {
|
|
395
|
+
await sleep(RATE_LIMIT_MS);
|
|
396
|
+
const more = await this.client.blocks.children.list({
|
|
397
|
+
block_id: pageId,
|
|
398
|
+
start_cursor: cursor,
|
|
399
|
+
page_size: 100,
|
|
400
|
+
});
|
|
401
|
+
for (const block of more.results) {
|
|
402
|
+
try {
|
|
403
|
+
await sleep(RATE_LIMIT_MS);
|
|
404
|
+
const deletePromise = this.client.blocks.delete({ block_id: block.id });
|
|
405
|
+
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('Block delete timeout')), 10000));
|
|
406
|
+
await Promise.race([deletePromise, timeoutPromise]);
|
|
407
|
+
}
|
|
408
|
+
catch {
|
|
409
|
+
// Continue on failure
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
cursor = more.has_more ? more.next_cursor : null;
|
|
413
|
+
}
|
|
414
|
+
catch {
|
|
415
|
+
break;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
async appendRemainingBlocks(pageId, blocks) {
|
|
421
|
+
for (let i = 0; i < blocks.length; i += MAX_BLOCKS_PER_APPEND) {
|
|
422
|
+
const batch = blocks.slice(i, i + MAX_BLOCKS_PER_APPEND);
|
|
423
|
+
await sleep(RATE_LIMIT_MS);
|
|
424
|
+
await this.client.blocks.children.append({
|
|
425
|
+
block_id: pageId,
|
|
426
|
+
children: batch,
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
// ============================================
|
|
431
|
+
// Mapping lookups
|
|
432
|
+
// ============================================
|
|
433
|
+
async findExistingNotionPage(kbDocumentId) {
|
|
434
|
+
// Check cache first
|
|
435
|
+
const cached = this.mappingCache.get(kbDocumentId);
|
|
436
|
+
if (cached)
|
|
437
|
+
return cached;
|
|
438
|
+
// Try notion-mapping API
|
|
439
|
+
try {
|
|
440
|
+
const response = await fetch(`${this.config.apiUrl}/api/v1/kb/notion-mappings/by-document/${kbDocumentId}`, {
|
|
441
|
+
headers: { 'x-api-key': this.config.apiKey },
|
|
442
|
+
});
|
|
443
|
+
if (response.ok) {
|
|
444
|
+
const data = (await response.json());
|
|
445
|
+
if (data.mapping?.notionPageId) {
|
|
446
|
+
this.mappingCache.set(kbDocumentId, data.mapping.notionPageId);
|
|
447
|
+
return data.mapping.notionPageId;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
catch {
|
|
452
|
+
// Mapping API might not be deployed yet - that's OK, treat as no mapping
|
|
453
|
+
}
|
|
454
|
+
return null;
|
|
455
|
+
}
|
|
456
|
+
async recordMapping(kbDocumentId, notionPageId) {
|
|
457
|
+
this.mappingCache.set(kbDocumentId, notionPageId);
|
|
458
|
+
// Try to save mapping via API (best-effort)
|
|
459
|
+
try {
|
|
460
|
+
await fetch(`${this.config.apiUrl}/api/v1/kb/notion-mappings`, {
|
|
461
|
+
method: 'POST',
|
|
462
|
+
headers: {
|
|
463
|
+
'Content-Type': 'application/json',
|
|
464
|
+
'x-api-key': this.config.apiKey,
|
|
465
|
+
},
|
|
466
|
+
body: JSON.stringify({
|
|
467
|
+
kbDocumentId,
|
|
468
|
+
notionPageId,
|
|
469
|
+
importSource: 'export',
|
|
470
|
+
syncDirection: 'kb_to_notion',
|
|
471
|
+
}),
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
catch {
|
|
475
|
+
// Best-effort — mapping API might not be available
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* to kb-migrate (local filesystem paths) — MoxnClient converts them to
|
|
9
9
|
* `type: 'storage'` before sending to the KB API.
|
|
10
10
|
*/
|
|
11
|
-
export type ContentBlock = TextBlock | ImageRemoteBlock | ImageFileBlock | DocumentRemoteBlock | DocumentFileBlock | CsvRemoteBlock | CsvFileBlock;
|
|
11
|
+
export type ContentBlock = TextBlock | ImageRemoteBlock | ImageFileBlock | DocumentRemoteBlock | DocumentFileBlock | CsvRemoteBlock | CsvFileBlock | DatabaseEmbedBlock;
|
|
12
12
|
export interface TextBlock {
|
|
13
13
|
blockType: 'text';
|
|
14
14
|
text: string;
|
|
@@ -65,6 +65,10 @@ export interface CsvFileBlock {
|
|
|
65
65
|
headers?: string[];
|
|
66
66
|
rowCount?: number;
|
|
67
67
|
}
|
|
68
|
+
export interface DatabaseEmbedBlock {
|
|
69
|
+
blockType: 'database_embed';
|
|
70
|
+
databaseId: string;
|
|
71
|
+
}
|
|
68
72
|
/**
|
|
69
73
|
* A section to be created in a document
|
|
70
74
|
*/
|
|
@@ -98,6 +102,12 @@ export interface ExtractedDocument {
|
|
|
98
102
|
sourcePath: string;
|
|
99
103
|
/** Cross-references discovered during resolution */
|
|
100
104
|
references?: ExtractedReference[];
|
|
105
|
+
/** Source-specific metadata (e.g., Notion page ID for bidirectional sync) */
|
|
106
|
+
metadata?: {
|
|
107
|
+
notionPageId?: string;
|
|
108
|
+
notionTitle?: string;
|
|
109
|
+
[key: string]: unknown;
|
|
110
|
+
};
|
|
101
111
|
}
|
|
102
112
|
/**
|
|
103
113
|
* Status of a single document migration
|
|
@@ -162,6 +172,8 @@ export interface MigrationOptions {
|
|
|
162
172
|
aiAccess?: 'edit' | 'read' | 'none';
|
|
163
173
|
/** Convenience flag: 'team' = read, 'private' = none */
|
|
164
174
|
visibility?: 'team' | 'private';
|
|
175
|
+
/** Date filter for source documents */
|
|
176
|
+
dateFilter?: import('./date-filter.js').DateFilter;
|
|
165
177
|
}
|
|
166
178
|
/**
|
|
167
179
|
* Error response from API when document already exists
|
|
@@ -175,13 +187,14 @@ export interface ConflictError {
|
|
|
175
187
|
* A content block as returned by the KB API
|
|
176
188
|
*/
|
|
177
189
|
export interface ExportContentBlock {
|
|
178
|
-
blockType: 'text' | 'image' | 'document' | 'csv';
|
|
190
|
+
blockType: 'text' | 'image' | 'document' | 'csv' | 'database_embed';
|
|
179
191
|
text?: string;
|
|
180
192
|
url?: string;
|
|
181
193
|
mimeType?: string;
|
|
182
194
|
alt?: string;
|
|
183
195
|
filename?: string;
|
|
184
196
|
storageKey?: string;
|
|
197
|
+
databaseId?: string;
|
|
185
198
|
}
|
|
186
199
|
/**
|
|
187
200
|
* A section within a document (API response)
|
|
@@ -201,6 +214,7 @@ export interface DocumentListItem {
|
|
|
201
214
|
name: string;
|
|
202
215
|
description: string | null;
|
|
203
216
|
createdAt: string;
|
|
217
|
+
updatedAt: string | null;
|
|
204
218
|
}
|
|
205
219
|
/**
|
|
206
220
|
* Full document detail with sections
|
|
@@ -271,4 +285,6 @@ export interface ExportOptions {
|
|
|
271
285
|
pdfDir: string;
|
|
272
286
|
csvDir: string;
|
|
273
287
|
dryRun: boolean;
|
|
288
|
+
/** Date filter for exported documents */
|
|
289
|
+
dateFilter?: import('./date-filter.js').DateFilter;
|
|
274
290
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@moxn/kb-migrate",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.7",
|
|
4
4
|
"description": "Migration tool for importing documents into Moxn Knowledge Base from local files, Notion, Google Docs, and more",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -49,6 +49,26 @@
|
|
|
49
49
|
"./client": {
|
|
50
50
|
"types": "./dist/client.d.ts",
|
|
51
51
|
"default": "./dist/client.js"
|
|
52
|
+
},
|
|
53
|
+
"./date-filter": {
|
|
54
|
+
"types": "./dist/date-filter.d.ts",
|
|
55
|
+
"default": "./dist/date-filter.js"
|
|
56
|
+
},
|
|
57
|
+
"./targets/base": {
|
|
58
|
+
"types": "./dist/targets/base.d.ts",
|
|
59
|
+
"default": "./dist/targets/base.js"
|
|
60
|
+
},
|
|
61
|
+
"./targets/notion": {
|
|
62
|
+
"types": "./dist/targets/notion.d.ts",
|
|
63
|
+
"default": "./dist/targets/notion.js"
|
|
64
|
+
},
|
|
65
|
+
"./targets": {
|
|
66
|
+
"types": "./dist/targets/index.d.ts",
|
|
67
|
+
"default": "./dist/targets/index.js"
|
|
68
|
+
},
|
|
69
|
+
"./export-notion": {
|
|
70
|
+
"types": "./dist/export-notion.d.ts",
|
|
71
|
+
"default": "./dist/export-notion.js"
|
|
52
72
|
}
|
|
53
73
|
},
|
|
54
74
|
"bin": {
|
|
@@ -62,6 +82,8 @@
|
|
|
62
82
|
"prepublishOnly": "npm run build"
|
|
63
83
|
},
|
|
64
84
|
"dependencies": {
|
|
85
|
+
"@notionhq/client": "^2.3.0",
|
|
86
|
+
"@tryfabric/martian": "^1.2.4",
|
|
65
87
|
"commander": "^12.0.0",
|
|
66
88
|
"glob": "^10.0.0",
|
|
67
89
|
"remark-parse": "^11.0.0",
|