@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 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,17 @@ 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
+ * Create or upsert a Notion database mapping.
85
+ */
86
+ createNotionDatabaseMapping(input: {
87
+ kbDatabaseId: string;
88
+ notionDatabaseId: string;
89
+ notionDatabaseTitle?: string;
90
+ }): Promise<{
91
+ mapping: {
92
+ id: string;
93
+ };
94
+ }>;
82
95
  private isConflictError;
83
96
  }
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,24 @@ export class MoxnClient {
393
401
  throw new Error(body.error || `Failed to assign tag: ${response.status}`);
394
402
  }
395
403
  }
404
+ /**
405
+ * Create or upsert a Notion database mapping.
406
+ */
407
+ async createNotionDatabaseMapping(input) {
408
+ const response = await fetch(`${this.apiUrl}/api/v1/kb/notion-mappings/databases`, {
409
+ method: 'POST',
410
+ headers: {
411
+ 'Content-Type': 'application/json',
412
+ 'x-api-key': this.apiKey,
413
+ },
414
+ body: JSON.stringify(input),
415
+ });
416
+ if (!response.ok) {
417
+ const body = await response.json().catch(() => ({}));
418
+ throw new Error(body.error || `Failed to create database mapping: ${response.status}`);
419
+ }
420
+ return response.json();
421
+ }
396
422
  isConflictError(error) {
397
423
  return (error instanceof Error &&
398
424
  '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,188 @@
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 { MoxnClient } from './client.js';
11
+ import { matchesDateFilter } from './date-filter.js';
12
+ import { NotionExportTarget } from './targets/notion.js';
13
+ export async function runNotionExport(options) {
14
+ const startTime = Date.now();
15
+ // Create Moxn client for reading docs
16
+ const client = new MoxnClient({
17
+ apiUrl: options.apiUrl,
18
+ apiKey: options.apiKey,
19
+ basePath: options.basePath,
20
+ imageDir: 'images',
21
+ pdfDir: 'pdfs',
22
+ csvDir: 'csvs',
23
+ dryRun: options.dryRun,
24
+ });
25
+ // Create Notion export target
26
+ const target = new NotionExportTarget({
27
+ notionToken: options.notionToken,
28
+ parentPageId: options.parentPageId,
29
+ conflictStrategy: options.conflictStrategy,
30
+ apiUrl: options.apiUrl,
31
+ apiKey: options.apiKey,
32
+ });
33
+ // Validate target
34
+ await target.validate();
35
+ // List documents
36
+ console.log('Fetching document list...');
37
+ const allDocuments = await client.listDocuments(options.basePath || undefined, options.dateFilter);
38
+ // Apply client-side date filtering
39
+ let documents = allDocuments;
40
+ if (options.dateFilter) {
41
+ documents = allDocuments.filter((doc) => matchesDateFilter(options.dateFilter, {
42
+ createdAt: doc.createdAt,
43
+ modifiedAt: doc.updatedAt,
44
+ }));
45
+ const skipped = allDocuments.length - documents.length;
46
+ if (skipped > 0) {
47
+ console.log(`Filtered ${skipped} documents by date criteria`);
48
+ }
49
+ }
50
+ console.log(`Found ${documents.length} documents to export`);
51
+ if (documents.length === 0) {
52
+ return buildEmptyLog(options, startTime);
53
+ }
54
+ // ============================================
55
+ // Pre-fetch all documents for two-pass export
56
+ // ============================================
57
+ console.log('Fetching document details...');
58
+ const docDetails = [];
59
+ for (let i = 0; i < documents.length; i++) {
60
+ const docItem = documents[i];
61
+ try {
62
+ const doc = await client.getDocument(docItem.id);
63
+ docDetails.push({ listItem: docItem, detail: doc });
64
+ // Register the document path -> ID mapping for reference resolution
65
+ target.registerDocument(doc);
66
+ }
67
+ catch (error) {
68
+ const message = error instanceof Error ? error.message : String(error);
69
+ console.log(` Warning: Failed to fetch ${docItem.path}: ${message}`);
70
+ }
71
+ }
72
+ // ============================================
73
+ // Pass 1: Create/update Notion pages
74
+ // ============================================
75
+ console.log(`\nPass 1: Exporting ${docDetails.length} documents to Notion...`);
76
+ const results = [];
77
+ for (let i = 0; i < docDetails.length; i++) {
78
+ const { listItem: docItem, detail: doc } = docDetails[i];
79
+ const progress = `(${i + 1}/${docDetails.length})`;
80
+ console.log(`Processing: ${docItem.path} ${progress}`);
81
+ try {
82
+ const result = await target.exportDocument(doc, docItem, options.dryRun);
83
+ results.push(result);
84
+ const statusIcon = {
85
+ created: '\u2713',
86
+ updated: '\u21bb',
87
+ skipped: '-',
88
+ failed: '\u2717',
89
+ }[result.status];
90
+ const extIdStr = result.externalId ? ` [${result.externalId}]` : '';
91
+ console.log(` ${statusIcon} ${result.status}: ${result.documentPath}${extIdStr}`);
92
+ if (result.error) {
93
+ console.log(` Error: ${result.error}`);
94
+ }
95
+ }
96
+ catch (error) {
97
+ const message = error instanceof Error ? error.message : String(error);
98
+ console.log(` \u2717 failed: ${docItem.path}`);
99
+ console.log(` Error: ${message}`);
100
+ results.push({
101
+ documentId: docItem.id,
102
+ documentPath: docItem.path,
103
+ status: 'failed',
104
+ error: message,
105
+ duration: 0,
106
+ });
107
+ }
108
+ }
109
+ // ============================================
110
+ // Pass 2: Resolve cross-references
111
+ // ============================================
112
+ if (!options.dryRun) {
113
+ // Find documents that have references and were successfully exported
114
+ const successfulExports = results.filter((r) => (r.status === 'created' || r.status === 'updated' || r.status === 'skipped') && r.externalId);
115
+ if (successfulExports.length > 0) {
116
+ console.log(`\nPass 2: Resolving cross-references for ${successfulExports.length} documents...`);
117
+ let totalResolved = 0;
118
+ let totalUnresolved = 0;
119
+ for (const result of successfulExports) {
120
+ const docDetail = docDetails.find((d) => d.detail.id === result.documentId);
121
+ if (!docDetail || !result.externalId)
122
+ continue;
123
+ try {
124
+ const { resolved, unresolved } = await target.resolveAndAppendReferences(docDetail.detail, result.externalId);
125
+ if (resolved > 0 || unresolved > 0) {
126
+ console.log(` ${docDetail.detail.path}: ${resolved} resolved, ${unresolved} unresolved`);
127
+ }
128
+ totalResolved += resolved;
129
+ totalUnresolved += unresolved;
130
+ }
131
+ catch (error) {
132
+ const message = error instanceof Error ? error.message : String(error);
133
+ console.log(` Warning: Reference resolution failed for ${docDetail.detail.path}: ${message}`);
134
+ }
135
+ }
136
+ if (totalResolved > 0 || totalUnresolved > 0) {
137
+ console.log(`\nReferences: ${totalResolved} resolved, ${totalUnresolved} unresolved`);
138
+ }
139
+ }
140
+ }
141
+ await target.cleanup();
142
+ return {
143
+ timestamp: new Date().toISOString(),
144
+ sourceApi: options.apiUrl,
145
+ target: {
146
+ type: target.targetType,
147
+ location: target.targetLocation,
148
+ },
149
+ basePath: options.basePath || '/',
150
+ options: {
151
+ dryRun: options.dryRun,
152
+ conflictStrategy: options.conflictStrategy,
153
+ },
154
+ results,
155
+ summary: {
156
+ total: results.length,
157
+ created: results.filter((r) => r.status === 'created').length,
158
+ updated: results.filter((r) => r.status === 'updated').length,
159
+ skipped: results.filter((r) => r.status === 'skipped').length,
160
+ failed: results.filter((r) => r.status === 'failed').length,
161
+ duration: Date.now() - startTime,
162
+ },
163
+ };
164
+ }
165
+ function buildEmptyLog(options, startTime) {
166
+ return {
167
+ timestamp: new Date().toISOString(),
168
+ sourceApi: options.apiUrl,
169
+ target: {
170
+ type: 'notion',
171
+ location: `Notion (parent: ${options.parentPageId})`,
172
+ },
173
+ basePath: options.basePath || '/',
174
+ options: {
175
+ dryRun: options.dryRun,
176
+ conflictStrategy: options.conflictStrategy,
177
+ },
178
+ results: [],
179
+ summary: {
180
+ total: 0,
181
+ created: 0,
182
+ updated: 0,
183
+ skipped: 0,
184
+ failed: 0,
185
+ duration: Date.now() - startTime,
186
+ },
187
+ };
188
+ }
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 {