@moxn/kb-migrate 0.3.0 → 0.4.0
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 +42 -0
- package/dist/client.js +115 -0
- package/dist/index.js +209 -6
- package/dist/sources/index.d.ts +1 -0
- package/dist/sources/index.js +1 -3
- package/dist/sources/notion-api.d.ts +240 -0
- package/dist/sources/notion-api.js +196 -0
- package/dist/sources/notion-blocks.d.ts +30 -0
- package/dist/sources/notion-blocks.js +505 -0
- package/dist/sources/notion-databases.d.ts +59 -0
- package/dist/sources/notion-databases.js +266 -0
- package/dist/sources/notion-media.d.ts +30 -0
- package/dist/sources/notion-media.js +133 -0
- package/dist/sources/notion.d.ts +66 -0
- package/dist/sources/notion.js +390 -0
- package/dist/types.d.ts +2 -0
- package/package.json +1 -1
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parses Notion database schemas and entries for import into Moxn KB.
|
|
3
|
+
*
|
|
4
|
+
* Mapping strategy:
|
|
5
|
+
* - select, multi_select, status → Moxn select/multi_select columns (backed by tags)
|
|
6
|
+
* - title → document name (not a column)
|
|
7
|
+
* - All other property types → rendered as markdown in a "Properties" section
|
|
8
|
+
*/
|
|
9
|
+
import { richTextToPlain, richTextToMarkdown, getPageTitle } from './notion-blocks.js';
|
|
10
|
+
// ============================================
|
|
11
|
+
// Schema Parsing
|
|
12
|
+
// ============================================
|
|
13
|
+
/**
|
|
14
|
+
* Parse a Notion database schema into mapped and unmapped columns.
|
|
15
|
+
*/
|
|
16
|
+
export function parseDatabaseSchema(db) {
|
|
17
|
+
const name = richTextToPlain(db.title) || 'Untitled Database';
|
|
18
|
+
const description = richTextToPlain(db.description);
|
|
19
|
+
const mappedColumns = [];
|
|
20
|
+
const unmappedColumns = [];
|
|
21
|
+
for (const [propName, prop] of Object.entries(db.properties)) {
|
|
22
|
+
// Skip title — it becomes the document name
|
|
23
|
+
if (prop.type === 'title')
|
|
24
|
+
continue;
|
|
25
|
+
if (prop.type === 'select' && prop.select) {
|
|
26
|
+
mappedColumns.push({
|
|
27
|
+
notionPropertyId: prop.id,
|
|
28
|
+
notionPropertyName: propName,
|
|
29
|
+
notionType: 'select',
|
|
30
|
+
moxnType: 'select',
|
|
31
|
+
options: prop.select.options.map((o) => ({
|
|
32
|
+
notionId: o.id,
|
|
33
|
+
name: o.name,
|
|
34
|
+
color: o.color,
|
|
35
|
+
})),
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
else if (prop.type === 'multi_select' && prop.multi_select) {
|
|
39
|
+
mappedColumns.push({
|
|
40
|
+
notionPropertyId: prop.id,
|
|
41
|
+
notionPropertyName: propName,
|
|
42
|
+
notionType: 'multi_select',
|
|
43
|
+
moxnType: 'multi_select',
|
|
44
|
+
options: prop.multi_select.options.map((o) => ({
|
|
45
|
+
notionId: o.id,
|
|
46
|
+
name: o.name,
|
|
47
|
+
color: o.color,
|
|
48
|
+
})),
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
else if (prop.type === 'status' && prop.status) {
|
|
52
|
+
// Status is structurally identical to select
|
|
53
|
+
mappedColumns.push({
|
|
54
|
+
notionPropertyId: prop.id,
|
|
55
|
+
notionPropertyName: propName,
|
|
56
|
+
notionType: 'status',
|
|
57
|
+
moxnType: 'select',
|
|
58
|
+
options: prop.status.options.map((o) => ({
|
|
59
|
+
notionId: o.id,
|
|
60
|
+
name: o.name,
|
|
61
|
+
color: o.color,
|
|
62
|
+
})),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
unmappedColumns.push({
|
|
67
|
+
notionPropertyId: prop.id,
|
|
68
|
+
notionPropertyName: propName,
|
|
69
|
+
notionType: prop.type,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return { name, description, mappedColumns, unmappedColumns };
|
|
74
|
+
}
|
|
75
|
+
// ============================================
|
|
76
|
+
// Entry Parsing
|
|
77
|
+
// ============================================
|
|
78
|
+
/**
|
|
79
|
+
* Parse a Notion database entry's property values.
|
|
80
|
+
*/
|
|
81
|
+
export function parseEntryValues(page, schema) {
|
|
82
|
+
const title = getPageTitle(page);
|
|
83
|
+
const tagValues = new Map();
|
|
84
|
+
const textValues = new Map();
|
|
85
|
+
const fileBlocks = [];
|
|
86
|
+
// Build lookup by property name
|
|
87
|
+
const mappedByName = new Map(schema.mappedColumns.map((c) => [c.notionPropertyName, c]));
|
|
88
|
+
const unmappedByName = new Set(schema.unmappedColumns.map((c) => c.notionPropertyName));
|
|
89
|
+
for (const [propName, propValue] of Object.entries(page.properties)) {
|
|
90
|
+
if (propValue.type === 'title')
|
|
91
|
+
continue; // Already extracted
|
|
92
|
+
// Check if this is a mapped column
|
|
93
|
+
const mapped = mappedByName.get(propName);
|
|
94
|
+
if (mapped) {
|
|
95
|
+
const values = extractSelectValues(propValue, mapped.notionType);
|
|
96
|
+
if (values.length > 0) {
|
|
97
|
+
tagValues.set(propName, values);
|
|
98
|
+
}
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
// Check if unmapped
|
|
102
|
+
if (unmappedByName.has(propName)) {
|
|
103
|
+
const rendered = renderPropertyValue(propValue);
|
|
104
|
+
if (rendered) {
|
|
105
|
+
textValues.set(propName, rendered);
|
|
106
|
+
}
|
|
107
|
+
// Extract file attachments
|
|
108
|
+
if (propValue.type === 'files' && propValue.files) {
|
|
109
|
+
for (const file of propValue.files) {
|
|
110
|
+
const url = file.type === 'file' ? file.file?.url : file.external?.url;
|
|
111
|
+
if (!url)
|
|
112
|
+
continue;
|
|
113
|
+
const isPdf = file.name.toLowerCase().endsWith('.pdf');
|
|
114
|
+
const isCsv = file.name.toLowerCase().endsWith('.csv');
|
|
115
|
+
if (isPdf) {
|
|
116
|
+
fileBlocks.push({
|
|
117
|
+
blockType: 'document',
|
|
118
|
+
type: 'url',
|
|
119
|
+
url,
|
|
120
|
+
mediaType: 'application/pdf',
|
|
121
|
+
filename: file.name,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
else if (isCsv) {
|
|
125
|
+
fileBlocks.push({
|
|
126
|
+
blockType: 'csv',
|
|
127
|
+
type: 'url',
|
|
128
|
+
url,
|
|
129
|
+
mediaType: 'text/csv',
|
|
130
|
+
filename: file.name,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
else if (isImageFilename(file.name)) {
|
|
134
|
+
fileBlocks.push({
|
|
135
|
+
blockType: 'image',
|
|
136
|
+
type: 'url',
|
|
137
|
+
url,
|
|
138
|
+
mediaType: guessImageMime(file.name),
|
|
139
|
+
alt: file.name,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return { title, tagValues, textValues, fileBlocks };
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Render unmapped properties as a markdown table section.
|
|
150
|
+
* Returns null if there are no unmapped values to render.
|
|
151
|
+
*/
|
|
152
|
+
export function renderPropertiesSection(values) {
|
|
153
|
+
if (values.textValues.size === 0 && values.fileBlocks.length === 0) {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
const content = [];
|
|
157
|
+
// Render text properties as a markdown table
|
|
158
|
+
if (values.textValues.size > 0) {
|
|
159
|
+
const lines = ['| Property | Value |', '| --- | --- |'];
|
|
160
|
+
for (const [name, value] of values.textValues) {
|
|
161
|
+
// Escape pipes in values
|
|
162
|
+
const escaped = value.replace(/\|/g, '\\|').replace(/\n/g, ' ');
|
|
163
|
+
lines.push(`| ${name} | ${escaped} |`);
|
|
164
|
+
}
|
|
165
|
+
content.push({ blockType: 'text', text: lines.join('\n') });
|
|
166
|
+
}
|
|
167
|
+
// Add file blocks
|
|
168
|
+
content.push(...values.fileBlocks);
|
|
169
|
+
return { name: 'Properties', content };
|
|
170
|
+
}
|
|
171
|
+
// ============================================
|
|
172
|
+
// Value extractors
|
|
173
|
+
// ============================================
|
|
174
|
+
function extractSelectValues(propValue, notionType) {
|
|
175
|
+
switch (notionType) {
|
|
176
|
+
case 'select':
|
|
177
|
+
return propValue.select ? [propValue.select.name] : [];
|
|
178
|
+
case 'multi_select':
|
|
179
|
+
return (propValue.multi_select ?? []).map((s) => s.name);
|
|
180
|
+
case 'status':
|
|
181
|
+
return propValue.status ? [propValue.status.name] : [];
|
|
182
|
+
default:
|
|
183
|
+
return [];
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
function renderPropertyValue(prop) {
|
|
187
|
+
switch (prop.type) {
|
|
188
|
+
case 'rich_text':
|
|
189
|
+
return richTextToMarkdown(prop.rich_text ?? []) || null;
|
|
190
|
+
case 'number':
|
|
191
|
+
return prop.number != null ? String(prop.number) : null;
|
|
192
|
+
case 'date':
|
|
193
|
+
if (!prop.date)
|
|
194
|
+
return null;
|
|
195
|
+
return prop.date.end ? `${prop.date.start} → ${prop.date.end}` : prop.date.start;
|
|
196
|
+
case 'checkbox':
|
|
197
|
+
return prop.checkbox != null ? (prop.checkbox ? 'Yes' : 'No') : null;
|
|
198
|
+
case 'url':
|
|
199
|
+
return prop.url ? `[${prop.url}](${prop.url})` : null;
|
|
200
|
+
case 'email':
|
|
201
|
+
return prop.email ?? null;
|
|
202
|
+
case 'phone_number':
|
|
203
|
+
return prop.phone_number ?? null;
|
|
204
|
+
case 'people':
|
|
205
|
+
if (!prop.people || prop.people.length === 0)
|
|
206
|
+
return null;
|
|
207
|
+
return prop.people.map((p) => p.name ?? p.id).join(', ');
|
|
208
|
+
case 'relation':
|
|
209
|
+
if (!prop.relation || prop.relation.length === 0)
|
|
210
|
+
return null;
|
|
211
|
+
return prop.relation.map((r) => r.id).join(', ');
|
|
212
|
+
case 'formula':
|
|
213
|
+
if (!prop.formula)
|
|
214
|
+
return null;
|
|
215
|
+
if (prop.formula.string != null)
|
|
216
|
+
return prop.formula.string;
|
|
217
|
+
if (prop.formula.number != null)
|
|
218
|
+
return String(prop.formula.number);
|
|
219
|
+
if (prop.formula.boolean != null)
|
|
220
|
+
return prop.formula.boolean ? 'Yes' : 'No';
|
|
221
|
+
return null;
|
|
222
|
+
case 'rollup':
|
|
223
|
+
if (!prop.rollup)
|
|
224
|
+
return null;
|
|
225
|
+
if (prop.rollup.number != null)
|
|
226
|
+
return String(prop.rollup.number);
|
|
227
|
+
return null;
|
|
228
|
+
case 'files':
|
|
229
|
+
if (!prop.files || prop.files.length === 0)
|
|
230
|
+
return null;
|
|
231
|
+
return prop.files.map((f) => f.name).join(', ');
|
|
232
|
+
case 'created_time':
|
|
233
|
+
return prop.created_time ?? null;
|
|
234
|
+
case 'last_edited_time':
|
|
235
|
+
return prop.last_edited_time ?? null;
|
|
236
|
+
case 'created_by':
|
|
237
|
+
return (prop.created_by
|
|
238
|
+
?.name ?? null);
|
|
239
|
+
case 'last_edited_by':
|
|
240
|
+
return (prop
|
|
241
|
+
.last_edited_by?.name ?? null);
|
|
242
|
+
default:
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
// ============================================
|
|
247
|
+
// Helpers
|
|
248
|
+
// ============================================
|
|
249
|
+
function isImageFilename(name) {
|
|
250
|
+
const lower = name.toLowerCase();
|
|
251
|
+
return (lower.endsWith('.png') ||
|
|
252
|
+
lower.endsWith('.jpg') ||
|
|
253
|
+
lower.endsWith('.jpeg') ||
|
|
254
|
+
lower.endsWith('.gif') ||
|
|
255
|
+
lower.endsWith('.webp'));
|
|
256
|
+
}
|
|
257
|
+
function guessImageMime(name) {
|
|
258
|
+
const lower = name.toLowerCase();
|
|
259
|
+
if (lower.endsWith('.jpg') || lower.endsWith('.jpeg'))
|
|
260
|
+
return 'image/jpeg';
|
|
261
|
+
if (lower.endsWith('.gif'))
|
|
262
|
+
return 'image/gif';
|
|
263
|
+
if (lower.endsWith('.webp'))
|
|
264
|
+
return 'image/webp';
|
|
265
|
+
return 'image/png';
|
|
266
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Downloads media files from Notion's signed URLs to a temp directory.
|
|
3
|
+
*
|
|
4
|
+
* Signed URLs expire in ~1 hour, so files must be downloaded
|
|
5
|
+
* immediately during extraction.
|
|
6
|
+
*/
|
|
7
|
+
import type { ContentBlock } from '../types.js';
|
|
8
|
+
export declare class NotionMediaDownloader {
|
|
9
|
+
private tempDir;
|
|
10
|
+
private downloadedFiles;
|
|
11
|
+
/** Initialize the temp directory. */
|
|
12
|
+
init(): Promise<void>;
|
|
13
|
+
/** Clean up the temp directory and all downloaded files. */
|
|
14
|
+
cleanup(): Promise<void>;
|
|
15
|
+
/**
|
|
16
|
+
* Process content blocks: download any Notion-hosted files
|
|
17
|
+
* and convert from `type: 'url'` to `type: 'file'`.
|
|
18
|
+
*
|
|
19
|
+
* External URLs (non-Notion) are left as-is.
|
|
20
|
+
* Only Notion signed URLs (containing "secure.notion-static.com" or
|
|
21
|
+
* "prod-files-secure" or "s3.us-west-2.amazonaws.com") are downloaded.
|
|
22
|
+
*/
|
|
23
|
+
processContentBlocks(blocks: ContentBlock[]): Promise<ContentBlock[]>;
|
|
24
|
+
/** Check if a content block has a Notion signed URL that needs downloading. */
|
|
25
|
+
private isNotionSignedUrl;
|
|
26
|
+
/** Download a single file and return the local path. */
|
|
27
|
+
private downloadFile;
|
|
28
|
+
/** Get file extension from URL or content block metadata. */
|
|
29
|
+
private getExtension;
|
|
30
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Downloads media files from Notion's signed URLs to a temp directory.
|
|
3
|
+
*
|
|
4
|
+
* Signed URLs expire in ~1 hour, so files must be downloaded
|
|
5
|
+
* immediately during extraction.
|
|
6
|
+
*/
|
|
7
|
+
import * as fs from 'fs/promises';
|
|
8
|
+
import * as path from 'path';
|
|
9
|
+
import * as os from 'os';
|
|
10
|
+
import * as crypto from 'crypto';
|
|
11
|
+
export class NotionMediaDownloader {
|
|
12
|
+
tempDir = null;
|
|
13
|
+
downloadedFiles = new Map(); // url → local path
|
|
14
|
+
/** Initialize the temp directory. */
|
|
15
|
+
async init() {
|
|
16
|
+
this.tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'moxn-notion-media-'));
|
|
17
|
+
}
|
|
18
|
+
/** Clean up the temp directory and all downloaded files. */
|
|
19
|
+
async cleanup() {
|
|
20
|
+
if (this.tempDir) {
|
|
21
|
+
await fs.rm(this.tempDir, { recursive: true, force: true }).catch(() => { });
|
|
22
|
+
this.tempDir = null;
|
|
23
|
+
}
|
|
24
|
+
this.downloadedFiles.clear();
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Process content blocks: download any Notion-hosted files
|
|
28
|
+
* and convert from `type: 'url'` to `type: 'file'`.
|
|
29
|
+
*
|
|
30
|
+
* External URLs (non-Notion) are left as-is.
|
|
31
|
+
* Only Notion signed URLs (containing "secure.notion-static.com" or
|
|
32
|
+
* "prod-files-secure" or "s3.us-west-2.amazonaws.com") are downloaded.
|
|
33
|
+
*/
|
|
34
|
+
async processContentBlocks(blocks) {
|
|
35
|
+
const results = [];
|
|
36
|
+
for (const block of blocks) {
|
|
37
|
+
if (this.isNotionSignedUrl(block)) {
|
|
38
|
+
try {
|
|
39
|
+
const localPath = await this.downloadFile(block.url, block);
|
|
40
|
+
results.push({
|
|
41
|
+
...block,
|
|
42
|
+
type: 'file',
|
|
43
|
+
path: localPath,
|
|
44
|
+
url: undefined,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
console.warn(` Warning: Failed to download ${block.blockType}: ${error instanceof Error ? error.message : error}`);
|
|
49
|
+
// Fall back to URL (may expire)
|
|
50
|
+
results.push(block);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
results.push(block);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return results;
|
|
58
|
+
}
|
|
59
|
+
/** Check if a content block has a Notion signed URL that needs downloading. */
|
|
60
|
+
isNotionSignedUrl(block) {
|
|
61
|
+
if (block.type !== 'url' || !block.url)
|
|
62
|
+
return false;
|
|
63
|
+
const url = block.url;
|
|
64
|
+
return (url.includes('secure.notion-static.com') ||
|
|
65
|
+
url.includes('prod-files-secure') ||
|
|
66
|
+
url.includes('s3.us-west-2.amazonaws.com') ||
|
|
67
|
+
url.includes('s3-us-west-2.amazonaws.com'));
|
|
68
|
+
}
|
|
69
|
+
/** Download a single file and return the local path. */
|
|
70
|
+
async downloadFile(url, block) {
|
|
71
|
+
// Check cache
|
|
72
|
+
const cached = this.downloadedFiles.get(url);
|
|
73
|
+
if (cached)
|
|
74
|
+
return cached;
|
|
75
|
+
if (!this.tempDir) {
|
|
76
|
+
throw new Error('NotionMediaDownloader not initialized. Call init() first.');
|
|
77
|
+
}
|
|
78
|
+
const response = await fetch(url);
|
|
79
|
+
if (!response.ok) {
|
|
80
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
81
|
+
}
|
|
82
|
+
const data = Buffer.from(await response.arrayBuffer());
|
|
83
|
+
// Determine filename
|
|
84
|
+
const ext = this.getExtension(url, block);
|
|
85
|
+
const hash = crypto.createHash('md5').update(url).digest('hex').slice(0, 8);
|
|
86
|
+
const filename = `${hash}${ext}`;
|
|
87
|
+
const localPath = path.join(this.tempDir, filename);
|
|
88
|
+
await fs.writeFile(localPath, data);
|
|
89
|
+
this.downloadedFiles.set(url, localPath);
|
|
90
|
+
return localPath;
|
|
91
|
+
}
|
|
92
|
+
/** Get file extension from URL or content block metadata. */
|
|
93
|
+
getExtension(url, block) {
|
|
94
|
+
// Try from mediaType
|
|
95
|
+
if (block.mediaType) {
|
|
96
|
+
const ext = mimeToExtension(block.mediaType);
|
|
97
|
+
if (ext)
|
|
98
|
+
return ext;
|
|
99
|
+
}
|
|
100
|
+
// Try from URL path
|
|
101
|
+
try {
|
|
102
|
+
const pathname = new URL(url).pathname;
|
|
103
|
+
const match = pathname.match(/\.([a-zA-Z0-9]+)(?:\?|$)/);
|
|
104
|
+
if (match)
|
|
105
|
+
return '.' + match[1].toLowerCase();
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
// Invalid URL
|
|
109
|
+
}
|
|
110
|
+
// Default based on block type
|
|
111
|
+
switch (block.blockType) {
|
|
112
|
+
case 'image':
|
|
113
|
+
return '.png';
|
|
114
|
+
case 'document':
|
|
115
|
+
return '.pdf';
|
|
116
|
+
case 'csv':
|
|
117
|
+
return '.csv';
|
|
118
|
+
default:
|
|
119
|
+
return '.bin';
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
function mimeToExtension(mime) {
|
|
124
|
+
const map = {
|
|
125
|
+
'image/png': '.png',
|
|
126
|
+
'image/jpeg': '.jpg',
|
|
127
|
+
'image/gif': '.gif',
|
|
128
|
+
'image/webp': '.webp',
|
|
129
|
+
'application/pdf': '.pdf',
|
|
130
|
+
'text/csv': '.csv',
|
|
131
|
+
};
|
|
132
|
+
return map[mime] ?? null;
|
|
133
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NotionSource — imports Notion workspace content into Moxn KB.
|
|
3
|
+
*
|
|
4
|
+
* Two-pass architecture:
|
|
5
|
+
* Pass 1 (validate): Discover all pages/databases via search API, build tree, compute paths
|
|
6
|
+
* Pass 2 (extract): Walk tree depth-first, fetch blocks, convert to sections
|
|
7
|
+
*
|
|
8
|
+
* Databases are imported after all pages: creates KB database + columns, links entries.
|
|
9
|
+
*/
|
|
10
|
+
import type { ExtractedDocument } from '../types.js';
|
|
11
|
+
import { MigrationSource, type SourceConfig } from './base.js';
|
|
12
|
+
import type { NotionPage } from './notion-api.js';
|
|
13
|
+
import { type ParsedDatabaseSchema } from './notion-databases.js';
|
|
14
|
+
export interface NotionSourceConfig extends SourceConfig {
|
|
15
|
+
token: string;
|
|
16
|
+
rootPageId?: string;
|
|
17
|
+
maxDepth?: number;
|
|
18
|
+
}
|
|
19
|
+
/** Info yielded to the migration runner for database import (post-document pass). */
|
|
20
|
+
export interface NotionDatabaseImportInfo {
|
|
21
|
+
notionDatabaseId: string;
|
|
22
|
+
schema: ParsedDatabaseSchema;
|
|
23
|
+
entries: Array<{
|
|
24
|
+
page: NotionPage;
|
|
25
|
+
/** The documentId already created for this entry page, if any. */
|
|
26
|
+
existingDocumentId?: string;
|
|
27
|
+
/** The branchId of the existing document. */
|
|
28
|
+
existingBranchId?: string;
|
|
29
|
+
kbPath: string;
|
|
30
|
+
}>;
|
|
31
|
+
}
|
|
32
|
+
export declare class NotionSource extends MigrationSource<NotionSourceConfig> {
|
|
33
|
+
private client;
|
|
34
|
+
private mediaDownloader;
|
|
35
|
+
private pageTree;
|
|
36
|
+
private allPages;
|
|
37
|
+
private pagePathMap;
|
|
38
|
+
private databases;
|
|
39
|
+
private databaseEntryPageIds;
|
|
40
|
+
private _documentCount;
|
|
41
|
+
constructor(config: NotionSourceConfig);
|
|
42
|
+
get sourceType(): string;
|
|
43
|
+
get sourceLocation(): string;
|
|
44
|
+
validate(): Promise<void>;
|
|
45
|
+
getDocumentCount(): Promise<number | undefined>;
|
|
46
|
+
/**
|
|
47
|
+
* Get database import info for the migration runner.
|
|
48
|
+
* Called after all pages are imported to create databases and link entries.
|
|
49
|
+
*/
|
|
50
|
+
getDatabaseImports(): NotionDatabaseImportInfo[];
|
|
51
|
+
extract(): AsyncGenerator<ExtractedDocument, void, unknown>;
|
|
52
|
+
/** Clean up temp files after migration completes. */
|
|
53
|
+
cleanup(): Promise<void>;
|
|
54
|
+
private buildPageTree;
|
|
55
|
+
private getParentPageId;
|
|
56
|
+
private processDatabases;
|
|
57
|
+
private getDatabaseParentId;
|
|
58
|
+
private extractPage;
|
|
59
|
+
private extractDatabaseEntry;
|
|
60
|
+
private downloadSectionMedia;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Slugify a page title for use as a KB path segment.
|
|
64
|
+
* Lowercase, spaces→hyphens, strip special chars, ltree-compatible.
|
|
65
|
+
*/
|
|
66
|
+
export declare function slugify(title: string): string;
|