@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 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 documents = await client.listDocuments(options.basePath || undefined);
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 {