@moxn/kb-migrate 0.1.1 → 0.2.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 CHANGED
@@ -1,17 +1,30 @@
1
1
  /**
2
2
  * API client for Moxn KB
3
3
  */
4
- import type { ExtractedDocument, MigrationOptions, MigrationResult } from './types.js';
4
+ import type { ExtractedDocument, MigrationOptions, MigrationResult, DocumentListItem, DocumentDetail, ExportOptions } from './types.js';
5
5
  export declare class MoxnClient {
6
6
  private apiUrl;
7
7
  private apiKey;
8
8
  private defaultPermission?;
9
9
  private aiAccess?;
10
- constructor(options: MigrationOptions);
10
+ constructor(options: MigrationOptions | ExportOptions);
11
11
  /**
12
12
  * Migrate a single document
13
13
  */
14
14
  migrateDocument(doc: ExtractedDocument, basePath: string, onConflict: 'skip' | 'update', dryRun: boolean): Promise<MigrationResult>;
15
+ /**
16
+ * List all documents, optionally filtered by path prefix.
17
+ * Handles pagination automatically.
18
+ */
19
+ listDocuments(pathPrefix?: string): Promise<DocumentListItem[]>;
20
+ /**
21
+ * Get full document detail with sections and content.
22
+ */
23
+ getDocument(documentId: string): Promise<DocumentDetail>;
24
+ /**
25
+ * Download a file from a URL to a local path.
26
+ */
27
+ downloadFile(url: string, destPath: string): Promise<void>;
15
28
  private buildPath;
16
29
  private processSections;
17
30
  private processContentBlocks;
package/dist/client.js CHANGED
@@ -10,8 +10,12 @@ export class MoxnClient {
10
10
  constructor(options) {
11
11
  this.apiUrl = options.apiUrl.replace(/\/$/, '');
12
12
  this.apiKey = options.apiKey;
13
- this.defaultPermission = options.defaultPermission;
14
- this.aiAccess = options.aiAccess;
13
+ if ('defaultPermission' in options) {
14
+ this.defaultPermission = options.defaultPermission;
15
+ }
16
+ if ('aiAccess' in options) {
17
+ this.aiAccess = options.aiAccess;
18
+ }
15
19
  }
16
20
  /**
17
21
  * Migrate a single document
@@ -101,6 +105,64 @@ export class MoxnClient {
101
105
  };
102
106
  }
103
107
  }
108
+ // ──────────────────────────────────────────────
109
+ // Export methods
110
+ // ──────────────────────────────────────────────
111
+ /**
112
+ * List all documents, optionally filtered by path prefix.
113
+ * Handles pagination automatically.
114
+ */
115
+ async listDocuments(pathPrefix) {
116
+ const allDocs = [];
117
+ let offset = 0;
118
+ const limit = 100;
119
+ while (true) {
120
+ const params = new URLSearchParams({ limit: String(limit), offset: String(offset) });
121
+ if (pathPrefix) {
122
+ params.set('path', pathPrefix);
123
+ }
124
+ const response = await fetch(`${this.apiUrl}/api/v1/kb/documents?${params}`, {
125
+ headers: { 'x-api-key': this.apiKey },
126
+ });
127
+ if (!response.ok) {
128
+ const error = await response.text();
129
+ throw new Error(`Failed to list documents: ${response.status} ${error}`);
130
+ }
131
+ const data = await response.json();
132
+ allDocs.push(...data.documents);
133
+ if (!data.pagination.hasMore)
134
+ break;
135
+ offset += limit;
136
+ }
137
+ return allDocs;
138
+ }
139
+ /**
140
+ * Get full document detail with sections and content.
141
+ */
142
+ async getDocument(documentId) {
143
+ const response = await fetch(`${this.apiUrl}/api/v1/kb/documents/${documentId}`, {
144
+ headers: { 'x-api-key': this.apiKey },
145
+ });
146
+ if (!response.ok) {
147
+ const error = await response.text();
148
+ throw new Error(`Failed to get document ${documentId}: ${response.status} ${error}`);
149
+ }
150
+ return response.json();
151
+ }
152
+ /**
153
+ * Download a file from a URL to a local path.
154
+ */
155
+ async downloadFile(url, destPath) {
156
+ const response = await fetch(url);
157
+ if (!response.ok) {
158
+ throw new Error(`Failed to download ${url}: ${response.status}`);
159
+ }
160
+ const buffer = Buffer.from(await response.arrayBuffer());
161
+ await fs.writeFile(destPath, buffer);
162
+ }
163
+ // ──────────────────────────────────────────────
164
+ // Migration methods
165
+ // ──────────────────────────────────────────────
104
166
  buildPath(basePath, relativePath) {
105
167
  const base = basePath.replace(/^\/+|\/+$/g, '');
106
168
  const rel = relativePath.replace(/^\/+|\/+$/g, '');
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Export runner for Moxn KB
3
+ *
4
+ * Exports documents from Moxn Knowledge Base to local markdown files
5
+ * with downloaded media assets (images, PDFs, CSVs).
6
+ */
7
+ import type { ExportOptions, ExportLog } from './types.js';
8
+ export declare function runExport(outputDir: string, options: ExportOptions): Promise<ExportLog>;
package/dist/export.js ADDED
@@ -0,0 +1,269 @@
1
+ /**
2
+ * Export runner for Moxn KB
3
+ *
4
+ * Exports documents from Moxn Knowledge Base to local markdown files
5
+ * with downloaded media assets (images, PDFs, CSVs).
6
+ */
7
+ import * as fs from 'fs';
8
+ import * as path from 'path';
9
+ import { MoxnClient } from './client.js';
10
+ // ──────────────────────────────────────────────
11
+ // Utility functions
12
+ // ──────────────────────────────────────────────
13
+ /** Strip <moxn:comment> tags from text, preserving the inner text. */
14
+ function stripCommentTags(text) {
15
+ return text.replace(/<moxn:comment[^>]*>([\s\S]*?)<\/moxn:comment>/g, '$1');
16
+ }
17
+ /** Derive a local filename from a storage key or URL. */
18
+ function deriveFilename(storageKey, url, fallbackExt) {
19
+ if (storageKey) {
20
+ const parts = storageKey.split('/');
21
+ return parts[parts.length - 1];
22
+ }
23
+ try {
24
+ const urlPath = new URL(url).pathname;
25
+ const basename = urlPath.split('/').pop();
26
+ if (basename && basename.includes('.')) {
27
+ return basename;
28
+ }
29
+ }
30
+ catch {
31
+ // Not a valid URL
32
+ }
33
+ return `export-${Date.now()}${fallbackExt}`;
34
+ }
35
+ /** Get file extension from MIME type. */
36
+ function mimeToExt(mimeType) {
37
+ const map = {
38
+ 'image/png': '.png',
39
+ 'image/jpeg': '.jpg',
40
+ 'image/gif': '.gif',
41
+ 'image/webp': '.webp',
42
+ 'application/pdf': '.pdf',
43
+ 'text/csv': '.csv',
44
+ };
45
+ return map[mimeType] || '';
46
+ }
47
+ /** Convert a document path to a local file path. */
48
+ function docPathToFilePath(docPath) {
49
+ return `${docPath.replace(/^\//, '')}.md`;
50
+ }
51
+ // ──────────────────────────────────────────────
52
+ // Markdown builder
53
+ // ──────────────────────────────────────────────
54
+ function buildMarkdown(doc, mediaMap, mdFilePath) {
55
+ const lines = [];
56
+ lines.push(`# ${doc.name}`);
57
+ lines.push('');
58
+ if (doc.description) {
59
+ lines.push(doc.description);
60
+ lines.push('');
61
+ }
62
+ for (const section of doc.sections) {
63
+ lines.push(`## ${section.name}`);
64
+ lines.push('');
65
+ for (const block of section.content) {
66
+ if (block.blockType === 'text' && block.text) {
67
+ lines.push(stripCommentTags(block.text));
68
+ lines.push('');
69
+ }
70
+ else if (block.blockType === 'image' && block.url) {
71
+ const key = block.storageKey || block.url;
72
+ const localPath = mediaMap.get(key);
73
+ if (localPath) {
74
+ const relativePath = path.relative(path.dirname(mdFilePath), localPath);
75
+ lines.push(`![${block.alt || ''}](${relativePath})`);
76
+ }
77
+ else {
78
+ lines.push(`![${block.alt || ''}](${block.url})`);
79
+ }
80
+ lines.push('');
81
+ }
82
+ else if (block.blockType === 'document' && block.url) {
83
+ const key = block.storageKey || block.url;
84
+ const localPath = mediaMap.get(key);
85
+ if (localPath) {
86
+ const relativePath = path.relative(path.dirname(mdFilePath), localPath);
87
+ lines.push(`[${block.filename || 'document'}](${relativePath})`);
88
+ }
89
+ else {
90
+ lines.push(`[${block.filename || 'document'}](${block.url})`);
91
+ }
92
+ lines.push('');
93
+ }
94
+ else if (block.blockType === 'csv' && block.url) {
95
+ const key = block.storageKey || block.url;
96
+ const localPath = mediaMap.get(key);
97
+ if (localPath) {
98
+ const relativePath = path.relative(path.dirname(mdFilePath), localPath);
99
+ lines.push(`[${block.filename || 'data.csv'}](${relativePath})`);
100
+ }
101
+ else {
102
+ lines.push(`[${block.filename || 'data.csv'}](${block.url})`);
103
+ }
104
+ lines.push('');
105
+ }
106
+ }
107
+ }
108
+ // Trim trailing blank lines, end with single newline
109
+ while (lines.length > 0 && lines[lines.length - 1].trim() === '') {
110
+ lines.pop();
111
+ }
112
+ lines.push('');
113
+ return lines.join('\n');
114
+ }
115
+ // ──────────────────────────────────────────────
116
+ // Media helpers
117
+ // ──────────────────────────────────────────────
118
+ function getMediaTarget(block, options) {
119
+ if (block.blockType === 'image') {
120
+ return { targetDir: options.imageDir, fallbackExt: mimeToExt(block.mimeType || 'image/png') };
121
+ }
122
+ else if (block.blockType === 'document') {
123
+ return {
124
+ targetDir: options.pdfDir,
125
+ fallbackExt: mimeToExt(block.mimeType || 'application/pdf'),
126
+ };
127
+ }
128
+ else if (block.blockType === 'csv') {
129
+ return { targetDir: options.csvDir, fallbackExt: '.csv' };
130
+ }
131
+ return null;
132
+ }
133
+ // ──────────────────────────────────────────────
134
+ // Main export runner
135
+ // ──────────────────────────────────────────────
136
+ export async function runExport(outputDir, options) {
137
+ const startTime = Date.now();
138
+ const client = new MoxnClient(options);
139
+ // List documents
140
+ console.log('Fetching document list...');
141
+ const documents = await client.listDocuments(options.basePath || undefined);
142
+ console.log(`Found ${documents.length} documents`);
143
+ if (documents.length === 0) {
144
+ return {
145
+ timestamp: new Date().toISOString(),
146
+ sourceApi: options.apiUrl,
147
+ outputDir: path.resolve(outputDir),
148
+ basePath: options.basePath || '/',
149
+ options: {
150
+ dryRun: options.dryRun,
151
+ imageDir: options.imageDir,
152
+ pdfDir: options.pdfDir,
153
+ csvDir: options.csvDir,
154
+ },
155
+ results: [],
156
+ summary: {
157
+ total: 0,
158
+ exported: 0,
159
+ failed: 0,
160
+ mediaDownloaded: 0,
161
+ duration: Date.now() - startTime,
162
+ },
163
+ };
164
+ }
165
+ // Create output directories
166
+ if (!options.dryRun) {
167
+ fs.mkdirSync(outputDir, { recursive: true });
168
+ fs.mkdirSync(path.join(outputDir, options.imageDir), { recursive: true });
169
+ fs.mkdirSync(path.join(outputDir, options.pdfDir), { recursive: true });
170
+ fs.mkdirSync(path.join(outputDir, options.csvDir), { recursive: true });
171
+ }
172
+ const results = [];
173
+ let totalMediaDownloaded = 0;
174
+ for (const docItem of documents) {
175
+ const docStart = Date.now();
176
+ const mediaFiles = [];
177
+ console.log(`Processing: ${docItem.path}`);
178
+ try {
179
+ const doc = await client.getDocument(docItem.id);
180
+ const mdRelativePath = docPathToFilePath(doc.path);
181
+ const mdFullPath = path.join(outputDir, mdRelativePath);
182
+ const mediaMap = new Map();
183
+ // Download media
184
+ for (const section of doc.sections) {
185
+ for (const block of section.content) {
186
+ if (block.blockType === 'text')
187
+ continue;
188
+ if (!block.url)
189
+ continue;
190
+ if (block.url.startsWith('data:'))
191
+ continue;
192
+ const target = getMediaTarget(block, options);
193
+ if (!target)
194
+ continue;
195
+ const filename = deriveFilename(block.storageKey, block.url, target.fallbackExt);
196
+ const localRelativePath = path.join(target.targetDir, filename);
197
+ const localFullPath = path.join(outputDir, localRelativePath);
198
+ const mapKey = block.storageKey || block.url;
199
+ mediaMap.set(mapKey, path.join(outputDir, localRelativePath));
200
+ if (!options.dryRun) {
201
+ if (!fs.existsSync(localFullPath)) {
202
+ try {
203
+ await client.downloadFile(block.url, localFullPath);
204
+ totalMediaDownloaded++;
205
+ }
206
+ catch (err) {
207
+ const msg = err instanceof Error ? err.message : String(err);
208
+ console.log(` \u2717 Download failed: ${filename}: ${msg}`);
209
+ }
210
+ }
211
+ }
212
+ else {
213
+ totalMediaDownloaded++;
214
+ }
215
+ mediaFiles.push(localRelativePath);
216
+ }
217
+ }
218
+ const markdown = buildMarkdown(doc, mediaMap, mdFullPath);
219
+ if (!options.dryRun) {
220
+ fs.mkdirSync(path.dirname(mdFullPath), { recursive: true });
221
+ fs.writeFileSync(mdFullPath, markdown, 'utf-8');
222
+ }
223
+ console.log(` \u2713 ${mdRelativePath} (${doc.sections.length} sections, ${mediaFiles.length} media)`);
224
+ results.push({
225
+ documentId: doc.id,
226
+ documentPath: doc.path,
227
+ outputFile: mdRelativePath,
228
+ status: 'exported',
229
+ sectionsCount: doc.sections.length,
230
+ mediaFiles,
231
+ duration: Date.now() - docStart,
232
+ });
233
+ }
234
+ catch (error) {
235
+ const message = error instanceof Error ? error.message : String(error);
236
+ console.log(` \u2717 Failed: ${message}`);
237
+ results.push({
238
+ documentId: docItem.id,
239
+ documentPath: docItem.path,
240
+ outputFile: docPathToFilePath(docItem.path),
241
+ status: 'failed',
242
+ sectionsCount: 0,
243
+ mediaFiles: [],
244
+ error: message,
245
+ duration: Date.now() - docStart,
246
+ });
247
+ }
248
+ }
249
+ return {
250
+ timestamp: new Date().toISOString(),
251
+ sourceApi: options.apiUrl,
252
+ outputDir: path.resolve(outputDir),
253
+ basePath: options.basePath || '/',
254
+ options: {
255
+ dryRun: options.dryRun,
256
+ imageDir: options.imageDir,
257
+ pdfDir: options.pdfDir,
258
+ csvDir: options.csvDir,
259
+ },
260
+ results,
261
+ summary: {
262
+ total: results.length,
263
+ exported: results.filter((r) => r.status === 'exported').length,
264
+ failed: results.filter((r) => r.status === 'failed').length,
265
+ mediaDownloaded: totalMediaDownloaded,
266
+ duration: Date.now() - startTime,
267
+ },
268
+ };
269
+ }
package/dist/index.js CHANGED
@@ -15,6 +15,7 @@
15
15
  import { Command } from 'commander';
16
16
  import { LocalSource } from './sources/local.js';
17
17
  import { MoxnClient } from './client.js';
18
+ import { runExport } from './export.js';
18
19
  const DEFAULT_API_URL = 'https://moxn.dev';
19
20
  const DEFAULT_EXTENSIONS = ['.md', '.txt'];
20
21
  async function runMigration(source, options) {
@@ -91,6 +92,27 @@ function printSummary(log) {
91
92
  console.log('\n(Dry run - no changes made)');
92
93
  }
93
94
  }
95
+ function printExportSummary(log) {
96
+ console.log('\n--- Export Summary ---');
97
+ console.log(`Source: ${log.sourceApi}`);
98
+ console.log(`Target: ${log.outputDir}`);
99
+ console.log(`Base path: ${log.basePath}`);
100
+ console.log(`Duration: ${(log.summary.duration / 1000).toFixed(1)}s`);
101
+ console.log('');
102
+ console.log(`Total: ${log.summary.total}`);
103
+ console.log(`Exported: ${log.summary.exported}`);
104
+ console.log(`Failed: ${log.summary.failed}`);
105
+ console.log(`Media: ${log.summary.mediaDownloaded}`);
106
+ if (log.summary.failed > 0) {
107
+ console.log('\nFailed documents:');
108
+ for (const f of log.results.filter((r) => r.status === 'failed')) {
109
+ console.log(` - ${f.documentPath}: ${f.error}`);
110
+ }
111
+ }
112
+ if (log.options.dryRun) {
113
+ console.log('\n(Dry run - no changes made)');
114
+ }
115
+ }
94
116
  const program = new Command();
95
117
  program
96
118
  .name('moxn-kb-migrate')
@@ -151,6 +173,49 @@ program
151
173
  process.exit(1);
152
174
  }
153
175
  });
176
+ program
177
+ .command('export <directory>')
178
+ .description('Export documents from Moxn Knowledge Base to local files')
179
+ .option('--api-key <key>', 'API key (or set MOXN_API_KEY env var)')
180
+ .option('--api-url <url>', 'API base URL', DEFAULT_API_URL)
181
+ .option('--base-path <path>', 'Only export docs under this path prefix', '/')
182
+ .option('--image-dir <name>', 'Directory name for images', 'images')
183
+ .option('--pdf-dir <name>', 'Directory name for PDFs', 'pdfs')
184
+ .option('--csv-dir <name>', 'Directory name for CSVs', 'csvs')
185
+ .option('--dry-run', 'Preview without writing files', false)
186
+ .option('--json', 'Output results as JSON', false)
187
+ .action(async (directory, opts) => {
188
+ const apiKey = opts.apiKey || process.env.MOXN_API_KEY;
189
+ if (!apiKey) {
190
+ console.error('Error: API key required. Use --api-key or set MOXN_API_KEY env var.');
191
+ process.exit(1);
192
+ }
193
+ const exportOptions = {
194
+ apiUrl: opts.apiUrl,
195
+ apiKey,
196
+ basePath: opts.basePath,
197
+ imageDir: opts.imageDir,
198
+ pdfDir: opts.pdfDir,
199
+ csvDir: opts.csvDir,
200
+ dryRun: opts.dryRun,
201
+ };
202
+ try {
203
+ const log = await runExport(directory, exportOptions);
204
+ if (opts.json) {
205
+ console.log(JSON.stringify(log, null, 2));
206
+ }
207
+ else {
208
+ printExportSummary(log);
209
+ }
210
+ if (log.summary.failed > 0) {
211
+ process.exit(1);
212
+ }
213
+ }
214
+ catch (error) {
215
+ console.error('Export failed:', error instanceof Error ? error.message : error);
216
+ process.exit(1);
217
+ }
218
+ });
154
219
  // Future: Add notion and google-docs commands here
155
220
  // program
156
221
  // .command('notion')
package/dist/types.d.ts CHANGED
@@ -107,3 +107,104 @@ export interface ConflictError {
107
107
  documentId: string;
108
108
  branchId: string;
109
109
  }
110
+ /**
111
+ * A content block as returned by the KB API
112
+ */
113
+ export interface ExportContentBlock {
114
+ blockType: 'text' | 'image' | 'document' | 'csv';
115
+ text?: string;
116
+ url?: string;
117
+ mimeType?: string;
118
+ alt?: string;
119
+ filename?: string;
120
+ storageKey?: string;
121
+ }
122
+ /**
123
+ * A section within a document (API response)
124
+ */
125
+ export interface DocumentSection {
126
+ id: string;
127
+ name: string;
128
+ position: number;
129
+ content: ExportContentBlock[];
130
+ }
131
+ /**
132
+ * A document in the list response
133
+ */
134
+ export interface DocumentListItem {
135
+ id: string;
136
+ path: string;
137
+ name: string;
138
+ description: string | null;
139
+ createdAt: string;
140
+ }
141
+ /**
142
+ * Full document detail with sections
143
+ */
144
+ export interface DocumentDetail {
145
+ id: string;
146
+ path: string;
147
+ name: string;
148
+ description: string | null;
149
+ sections: DocumentSection[];
150
+ }
151
+ /**
152
+ * Paginated list response from KB API
153
+ */
154
+ export interface ListResponse {
155
+ documents: DocumentListItem[];
156
+ pagination: {
157
+ total: number;
158
+ limit: number;
159
+ offset: number;
160
+ hasMore: boolean;
161
+ };
162
+ }
163
+ /**
164
+ * Result of exporting a single document
165
+ */
166
+ export interface ExportResult {
167
+ documentId: string;
168
+ documentPath: string;
169
+ outputFile: string;
170
+ status: 'exported' | 'failed';
171
+ sectionsCount: number;
172
+ mediaFiles: string[];
173
+ error?: string;
174
+ duration: number;
175
+ }
176
+ /**
177
+ * Complete export log for JSON output
178
+ */
179
+ export interface ExportLog {
180
+ timestamp: string;
181
+ sourceApi: string;
182
+ outputDir: string;
183
+ basePath: string;
184
+ options: {
185
+ dryRun: boolean;
186
+ imageDir: string;
187
+ pdfDir: string;
188
+ csvDir: string;
189
+ };
190
+ results: ExportResult[];
191
+ summary: {
192
+ total: number;
193
+ exported: number;
194
+ failed: number;
195
+ mediaDownloaded: number;
196
+ duration: number;
197
+ };
198
+ }
199
+ /**
200
+ * Options for export
201
+ */
202
+ export interface ExportOptions {
203
+ apiUrl: string;
204
+ apiKey: string;
205
+ basePath: string;
206
+ imageDir: string;
207
+ pdfDir: string;
208
+ csvDir: string;
209
+ dryRun: boolean;
210
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moxn/kb-migrate",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
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",