@moxn/kb-migrate 0.4.7 → 0.4.9

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 CHANGED
@@ -80,6 +80,61 @@ export declare class MoxnClient {
80
80
  * Assign a tag to a document.
81
81
  */
82
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
+ }>;
83
138
  /**
84
139
  * Create or upsert a Notion database mapping.
85
140
  */
package/dist/client.js CHANGED
@@ -401,6 +401,44 @@ export class MoxnClient {
401
401
  throw new Error(body.error || `Failed to assign tag: ${response.status}`);
402
402
  }
403
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
+ }
404
442
  /**
405
443
  * Create or upsert a Notion database mapping.
406
444
  */
@@ -7,9 +7,11 @@
7
7
  * Pass 1: Create/update all Notion pages (content without cross-references)
8
8
  * Pass 2: Resolve KB path references to Notion page IDs and append link blocks
9
9
  */
10
+ import { Client } from '@notionhq/client';
10
11
  import { MoxnClient } from './client.js';
11
12
  import { matchesDateFilter } from './date-filter.js';
12
13
  import { NotionExportTarget } from './targets/notion.js';
14
+ import { exportDatabase } from './targets/notion-database-export.js';
13
15
  export async function runNotionExport(options) {
14
16
  const startTime = Date.now();
15
17
  // Create Moxn client for reading docs
@@ -107,6 +109,58 @@ export async function runNotionExport(options) {
107
109
  }
108
110
  }
109
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
+ // ============================================
110
164
  // Pass 2: Resolve cross-references
111
165
  // ============================================
112
166
  if (!options.dryRun) {
@@ -3,3 +3,4 @@
3
3
  */
4
4
  export { ExportTarget, type ExportTargetConfig, type ExportTargetResult, type ExportTargetLog } from './base.js';
5
5
  export { NotionExportTarget, type NotionExportConfig } from './notion.js';
6
+ export { exportDatabase, type DatabaseExportResult, type DatabaseExportContext } from './notion-database-export.js';
@@ -3,3 +3,4 @@
3
3
  */
4
4
  export { ExportTarget } from './base.js';
5
5
  export { NotionExportTarget } from './notion.js';
6
+ export { exportDatabase } 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,251 @@
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: { properties },
156
+ },
157
+ }));
158
+ const notionDatabaseId = response.id;
159
+ const dataSourceId = response.data_sources?.[0]?.id ?? notionDatabaseId;
160
+ return { notionDatabaseId, dataSourceId };
161
+ }
162
+ /**
163
+ * Update an existing Notion database by archiving all pages and recreating entries.
164
+ */
165
+ async function updateExistingNotionDatabase(ctx, notionDatabaseId, resolved) {
166
+ // Archive existing pages (Notion v5: query database, archive each page)
167
+ await sleep(RATE_LIMIT_MS);
168
+ let hasMore = true;
169
+ let startCursor;
170
+ while (hasMore) {
171
+ const queryResult = await ctx.notionClient.databases.query({
172
+ database_id: notionDatabaseId,
173
+ start_cursor: startCursor,
174
+ page_size: 100,
175
+ });
176
+ for (const page of queryResult.results) {
177
+ await sleep(RATE_LIMIT_MS);
178
+ try {
179
+ await ctx.notionClient.pages.update({
180
+ page_id: page.id,
181
+ archived: true,
182
+ });
183
+ }
184
+ catch {
185
+ // Continue if individual archive fails
186
+ }
187
+ }
188
+ hasMore = queryResult.has_more;
189
+ startCursor = queryResult.next_cursor ?? undefined;
190
+ }
191
+ // Now get the data_source_id for entry creation
192
+ // Query the database to get its data_sources
193
+ await sleep(RATE_LIMIT_MS);
194
+ const dbInfo = (await ctx.notionClient.request({
195
+ method: 'get',
196
+ path: `databases/${notionDatabaseId}`,
197
+ }));
198
+ const dataSourceId = dbInfo.data_sources?.[0]?.id ?? notionDatabaseId;
199
+ // Create new entries
200
+ return createDatabaseEntries(ctx, dataSourceId, resolved);
201
+ }
202
+ /**
203
+ * Create database entries as Notion pages.
204
+ */
205
+ async function createDatabaseEntries(ctx, dataSourceId, resolved) {
206
+ let created = 0;
207
+ for (const doc of resolved.documents) {
208
+ try {
209
+ // Build property values for this entry
210
+ const properties = {
211
+ // Title property (always "Name" for databases)
212
+ Name: {
213
+ type: 'title',
214
+ title: [{ type: 'text', text: { content: doc.documentName ?? 'Untitled' } }],
215
+ },
216
+ };
217
+ // Add column values
218
+ for (const [colName, propValue] of Object.entries(doc.properties)) {
219
+ if (propValue.values.length === 0)
220
+ continue;
221
+ if (propValue.columnType === 'select') {
222
+ properties[colName] = {
223
+ type: 'select',
224
+ select: { name: propValue.values[0].tagName },
225
+ };
226
+ }
227
+ else if (propValue.columnType === 'multi_select') {
228
+ properties[colName] = {
229
+ type: 'multi_select',
230
+ multi_select: propValue.values.map((v) => ({ name: v.tagName })),
231
+ };
232
+ }
233
+ }
234
+ await sleep(RATE_LIMIT_MS);
235
+ // Create entry as page with data_source_id parent
236
+ await ctx.notionClient.request({
237
+ method: 'post',
238
+ path: 'pages',
239
+ body: {
240
+ parent: { type: 'data_source_id', data_source_id: dataSourceId },
241
+ properties,
242
+ },
243
+ });
244
+ created++;
245
+ }
246
+ catch (error) {
247
+ console.error(` Warning: Failed to create entry "${doc.documentName}": ${error instanceof Error ? error.message : error}`);
248
+ }
249
+ }
250
+ return created;
251
+ }
@@ -46,6 +46,22 @@ function extractKBPathReferences(text) {
46
46
  });
47
47
  return { cleanedText, references };
48
48
  }
49
+ /**
50
+ * Strip invalid internal links from markdown text.
51
+ * Notion rejects links that aren't valid URLs (e.g., relative KB paths
52
+ * like "some/relative/path"). Convert them to plain text.
53
+ */
54
+ function stripInvalidLinks(text) {
55
+ // Match markdown links [text](url) where url is NOT a valid URL
56
+ return text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, displayText, url) => {
57
+ // Keep links that look like valid URLs (http/https/mailto/tel)
58
+ if (/^(https?:\/\/|mailto:|tel:|#)/.test(url)) {
59
+ return match;
60
+ }
61
+ // Strip relative/internal paths — just keep the display text
62
+ return displayText;
63
+ });
64
+ }
49
65
  /**
50
66
  * Convert a KB document's sections into a single markdown string.
51
67
  * Section names become H2 headings (mirrors the import convention).
@@ -56,6 +72,7 @@ function extractKBPathReferences(text) {
56
72
  function sectionsToMarkdown(sections, options) {
57
73
  const parts = [];
58
74
  const allReferences = [];
75
+ const databaseIds = [];
59
76
  const extractRefs = options?.extractReferences ?? false;
60
77
  for (const section of sections) {
61
78
  parts.push(`## ${section.name}\n`);
@@ -67,6 +84,9 @@ function sectionsToMarkdown(sections, options) {
67
84
  text = cleanedText;
68
85
  allReferences.push(...references);
69
86
  }
87
+ // Strip relative/internal links that aren't valid URLs
88
+ // (Notion rejects links without a protocol)
89
+ text = stripInvalidLinks(text);
70
90
  parts.push(text);
71
91
  parts.push('');
72
92
  }
@@ -82,9 +102,16 @@ function sectionsToMarkdown(sections, options) {
82
102
  parts.push(`[${block.filename || 'data.csv'}](${block.url})`);
83
103
  parts.push('');
84
104
  }
105
+ else if (block.blockType === 'database_embed' && block.databaseId) {
106
+ // Collect database ID for Pass 1.5 export
107
+ databaseIds.push(block.databaseId);
108
+ // Add a placeholder in the markdown
109
+ parts.push(`> **[Database]** *(exported as inline database)*`);
110
+ parts.push('');
111
+ }
85
112
  }
86
113
  }
87
- return { markdown: parts.join('\n').trim(), references: allReferences };
114
+ return { markdown: parts.join('\n').trim(), references: allReferences, databaseIds };
88
115
  }
89
116
  // Max 100 blocks per API call
90
117
  const MAX_BLOCKS_PER_APPEND = 100;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moxn/kb-migrate",
3
- "version": "0.4.7",
3
+ "version": "0.4.9",
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",