@moxn/kb-migrate 0.1.0 → 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')
@@ -16,6 +16,7 @@ export interface LocalSourceConfig extends SourceConfig {
16
16
  */
17
17
  export declare class LocalSource extends MigrationSource<LocalSourceConfig> {
18
18
  private files;
19
+ private skippedCollisions;
19
20
  get sourceType(): string;
20
21
  get sourceLocation(): string;
21
22
  validate(): Promise<void>;
@@ -25,6 +26,11 @@ export declare class LocalSource extends MigrationSource<LocalSourceConfig> {
25
26
  private extractDocument;
26
27
  private parseMarkdownSections;
27
28
  private nodesToContentBlocks;
29
+ /**
30
+ * Extract image nodes from paragraph children, returning image blocks
31
+ * and whether non-image text content exists.
32
+ */
33
+ private extractImagesFromParagraph;
28
34
  private imageToBlock;
29
35
  private guessImageType;
30
36
  private extensionToMediaType;
@@ -14,6 +14,7 @@ import { MigrationSource } from './base.js';
14
14
  */
15
15
  export class LocalSource extends MigrationSource {
16
16
  files = null;
17
+ skippedCollisions = new Set();
17
18
  get sourceType() {
18
19
  return 'local';
19
20
  }
@@ -30,13 +31,16 @@ export class LocalSource extends MigrationSource {
30
31
  if (!this.files) {
31
32
  await this.discoverFiles();
32
33
  }
33
- return this.files.length;
34
+ return this.files.length - this.skippedCollisions.size;
34
35
  }
35
36
  async *extract() {
36
37
  if (!this.files) {
37
38
  await this.discoverFiles();
38
39
  }
39
40
  for (const file of this.files) {
41
+ if (this.skippedCollisions.has(file)) {
42
+ continue;
43
+ }
40
44
  const doc = await this.extractDocument(file);
41
45
  if (doc) {
42
46
  yield doc;
@@ -55,7 +59,29 @@ export class LocalSource extends MigrationSource {
55
59
  allFiles.push(...matches);
56
60
  }
57
61
  // Deduplicate and sort
58
- this.files = [...new Set(allFiles)].sort();
62
+ const uniqueFiles = [...new Set(allFiles)].sort();
63
+ // Detect KB path collisions (e.g., doc.md and doc.mdx both map to /doc)
64
+ const pathToFiles = new Map();
65
+ for (const file of uniqueFiles) {
66
+ const parsed = path.parse(file);
67
+ const dirParts = parsed.dir ? parsed.dir.split(path.sep) : [];
68
+ const kbPath = [...dirParts, parsed.name].join('/').replace(/ /g, '-');
69
+ const existing = pathToFiles.get(kbPath) || [];
70
+ existing.push(file);
71
+ pathToFiles.set(kbPath, existing);
72
+ }
73
+ this.skippedCollisions = new Set();
74
+ for (const [kbPath, files] of pathToFiles) {
75
+ if (files.length > 1) {
76
+ console.warn(` ⚠ Path collision: multiple files map to KB path "/${kbPath}": ${files.join(', ')}`);
77
+ console.warn(` → Keeping "${files[0]}", skipping ${files.length - 1} duplicate(s)`);
78
+ // Skip all but the first file (alphabetically first since files are sorted)
79
+ for (let i = 1; i < files.length; i++) {
80
+ this.skippedCollisions.add(files[i]);
81
+ }
82
+ }
83
+ }
84
+ this.files = uniqueFiles;
59
85
  }
60
86
  async extractDocument(relativePath) {
61
87
  const fullPath = path.join(this.config.directory, relativePath);
@@ -63,7 +89,12 @@ export class LocalSource extends MigrationSource {
63
89
  // Parse relative path to create document path
64
90
  const parsed = path.parse(relativePath);
65
91
  const dirParts = parsed.dir ? parsed.dir.split(path.sep) : [];
66
- const docPath = [...dirParts, parsed.name].join('/');
92
+ const rawPath = [...dirParts, parsed.name].join('/');
93
+ // Sanitize: replace spaces with hyphens (server rejects paths with spaces)
94
+ const docPath = rawPath.replace(/ /g, '-');
95
+ if (docPath !== rawPath) {
96
+ console.warn(` ⚠ Path sanitized: "${rawPath}" → "${docPath}" (spaces replaced with hyphens)`);
97
+ }
67
98
  // Derive name from filename
68
99
  const name = parsed.name
69
100
  .split(/[-_]/)
@@ -158,12 +189,27 @@ export class LocalSource extends MigrationSource {
158
189
  };
159
190
  for (const node of nodes) {
160
191
  if (node.type === 'image') {
192
+ // Top-level image (rare in standard markdown, but possible)
161
193
  flushTextBuffer();
162
194
  const imageBlock = this.imageToBlock(node, baseDir);
163
195
  if (imageBlock) {
164
196
  blocks.push(imageBlock);
165
197
  }
166
198
  }
199
+ else if (node.type === 'paragraph') {
200
+ // Walk paragraph children to extract images separately
201
+ const paragraph = node;
202
+ const { images, hasText } = this.extractImagesFromParagraph(paragraph.children, baseDir);
203
+ if (hasText) {
204
+ // Render the paragraph text (images in text become markdown syntax via nodeToMarkdown)
205
+ textBuffer += this.nodeToMarkdown(node) + '\n\n';
206
+ }
207
+ // Emit extracted image blocks
208
+ if (images.length > 0) {
209
+ flushTextBuffer();
210
+ blocks.push(...images);
211
+ }
212
+ }
167
213
  else {
168
214
  textBuffer += this.nodeToMarkdown(node) + '\n\n';
169
215
  }
@@ -171,6 +217,30 @@ export class LocalSource extends MigrationSource {
171
217
  flushTextBuffer();
172
218
  return blocks;
173
219
  }
220
+ /**
221
+ * Extract image nodes from paragraph children, returning image blocks
222
+ * and whether non-image text content exists.
223
+ */
224
+ extractImagesFromParagraph(children, baseDir) {
225
+ const images = [];
226
+ let hasText = false;
227
+ for (const child of children) {
228
+ if (child.type === 'image') {
229
+ const imageBlock = this.imageToBlock(child, baseDir);
230
+ if (imageBlock) {
231
+ images.push(imageBlock);
232
+ }
233
+ }
234
+ else {
235
+ // Check if the child has any meaningful text
236
+ const text = this.nodeToMarkdown(child).trim();
237
+ if (text) {
238
+ hasText = true;
239
+ }
240
+ }
241
+ }
242
+ return { images, hasText };
243
+ }
174
244
  imageToBlock(node, baseDir) {
175
245
  const url = node.url;
176
246
  // Handle external URLs
@@ -272,8 +342,13 @@ export class LocalSource extends MigrationSource {
272
342
  case 'heading':
273
343
  const heading = node;
274
344
  const hashes = '#'.repeat(heading.depth);
275
- const headingText = heading.children.map((c) => this.nodeToMarkdown(c)).join('');
345
+ const headingText = heading.children
346
+ .map((c) => this.nodeToMarkdown(c))
347
+ .join('');
276
348
  return `${hashes} ${headingText}`;
349
+ case 'image':
350
+ const img = node;
351
+ return img.alt ? `![${img.alt}](${img.url})` : `![](${img.url})`;
277
352
  case 'thematicBreak':
278
353
  return '---';
279
354
  case 'table':
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.0",
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",