@moxn/kb-migrate 0.1.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.
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import '../dist/index.js';
@@ -0,0 +1,21 @@
1
+ /**
2
+ * API client for Moxn KB
3
+ */
4
+ import type { ExtractedDocument, MigrationOptions, MigrationResult } from './types.js';
5
+ export declare class MoxnClient {
6
+ private apiUrl;
7
+ private apiKey;
8
+ private defaultPermission?;
9
+ private aiAccess?;
10
+ constructor(options: MigrationOptions);
11
+ /**
12
+ * Migrate a single document
13
+ */
14
+ migrateDocument(doc: ExtractedDocument, basePath: string, onConflict: 'skip' | 'update', dryRun: boolean): Promise<MigrationResult>;
15
+ private buildPath;
16
+ private processSections;
17
+ private processContentBlocks;
18
+ private createDocument;
19
+ private updateDocument;
20
+ private isConflictError;
21
+ }
package/dist/client.js ADDED
@@ -0,0 +1,174 @@
1
+ /**
2
+ * API client for Moxn KB
3
+ */
4
+ import * as fs from 'fs/promises';
5
+ export class MoxnClient {
6
+ apiUrl;
7
+ apiKey;
8
+ defaultPermission;
9
+ aiAccess;
10
+ constructor(options) {
11
+ this.apiUrl = options.apiUrl.replace(/\/$/, '');
12
+ this.apiKey = options.apiKey;
13
+ this.defaultPermission = options.defaultPermission;
14
+ this.aiAccess = options.aiAccess;
15
+ }
16
+ /**
17
+ * Migrate a single document
18
+ */
19
+ async migrateDocument(doc, basePath, onConflict, dryRun) {
20
+ const startTime = Date.now();
21
+ const documentPath = this.buildPath(basePath, doc.relativePath);
22
+ if (dryRun) {
23
+ return {
24
+ sourcePath: doc.sourcePath,
25
+ documentPath,
26
+ status: 'skipped',
27
+ sectionsCount: doc.sections.length,
28
+ duration: Date.now() - startTime,
29
+ };
30
+ }
31
+ try {
32
+ // Process content blocks (convert file paths to base64)
33
+ const processedSections = await this.processSections(doc.sections);
34
+ // Try to create the document
35
+ const createResult = await this.createDocument({
36
+ path: documentPath,
37
+ name: doc.name,
38
+ description: doc.description,
39
+ defaultPermission: this.defaultPermission,
40
+ aiAccess: this.aiAccess,
41
+ sections: processedSections,
42
+ });
43
+ return {
44
+ sourcePath: doc.sourcePath,
45
+ documentPath,
46
+ status: 'created',
47
+ documentId: createResult.id,
48
+ branchId: createResult.branchId,
49
+ sectionsCount: createResult.sections.length,
50
+ duration: Date.now() - startTime,
51
+ };
52
+ }
53
+ catch (error) {
54
+ // Check for conflict (409)
55
+ if (this.isConflictError(error)) {
56
+ if (onConflict === 'skip') {
57
+ return {
58
+ sourcePath: doc.sourcePath,
59
+ documentPath,
60
+ status: 'skipped',
61
+ documentId: error.documentId,
62
+ branchId: error.branchId,
63
+ duration: Date.now() - startTime,
64
+ };
65
+ }
66
+ // Update existing document
67
+ try {
68
+ const processedSections = await this.processSections(doc.sections);
69
+ const updateResult = await this.updateDocument(error.documentId, {
70
+ name: doc.name,
71
+ description: doc.description,
72
+ sections: processedSections,
73
+ });
74
+ return {
75
+ sourcePath: doc.sourcePath,
76
+ documentPath,
77
+ status: 'updated',
78
+ documentId: updateResult.id,
79
+ branchId: updateResult.branchId,
80
+ sectionsCount: updateResult.sections.length,
81
+ duration: Date.now() - startTime,
82
+ };
83
+ }
84
+ catch (updateError) {
85
+ return {
86
+ sourcePath: doc.sourcePath,
87
+ documentPath,
88
+ status: 'failed',
89
+ documentId: error.documentId,
90
+ error: updateError instanceof Error ? updateError.message : 'Update failed',
91
+ duration: Date.now() - startTime,
92
+ };
93
+ }
94
+ }
95
+ return {
96
+ sourcePath: doc.sourcePath,
97
+ documentPath,
98
+ status: 'failed',
99
+ error: error instanceof Error ? error.message : 'Unknown error',
100
+ duration: Date.now() - startTime,
101
+ };
102
+ }
103
+ }
104
+ buildPath(basePath, relativePath) {
105
+ const base = basePath.replace(/^\/+|\/+$/g, '');
106
+ const rel = relativePath.replace(/^\/+|\/+$/g, '');
107
+ return '/' + (base ? `${base}/${rel}` : rel);
108
+ }
109
+ async processSections(sections) {
110
+ return Promise.all(sections.map(async (section) => ({
111
+ name: section.name,
112
+ content: await this.processContentBlocks(section.content),
113
+ })));
114
+ }
115
+ async processContentBlocks(blocks) {
116
+ return Promise.all(blocks.map(async (block) => {
117
+ if (block.blockType === 'image' && block.type === 'file' && block.path) {
118
+ // Convert local file to base64
119
+ const data = await fs.readFile(block.path);
120
+ return {
121
+ blockType: block.blockType,
122
+ type: 'base64',
123
+ base64: data.toString('base64'),
124
+ mediaType: block.mediaType,
125
+ alt: block.alt,
126
+ };
127
+ }
128
+ return block;
129
+ }));
130
+ }
131
+ async createDocument(request) {
132
+ const response = await fetch(`${this.apiUrl}/api/v1/kb/documents`, {
133
+ method: 'POST',
134
+ headers: {
135
+ 'Content-Type': 'application/json',
136
+ 'x-api-key': this.apiKey,
137
+ },
138
+ body: JSON.stringify(request),
139
+ });
140
+ if (!response.ok) {
141
+ const body = await response.json().catch(() => ({}));
142
+ if (response.status === 409 && body.documentId) {
143
+ const error = new Error(body.error || 'Document already exists');
144
+ error.documentId = body.documentId;
145
+ error.branchId = body.branchId;
146
+ throw error;
147
+ }
148
+ throw new Error(body.error || `API error: ${response.status}`);
149
+ }
150
+ return response.json();
151
+ }
152
+ async updateDocument(documentId, request) {
153
+ const response = await fetch(`${this.apiUrl}/api/v1/kb/documents/${documentId}`, {
154
+ method: 'PUT',
155
+ headers: {
156
+ 'Content-Type': 'application/json',
157
+ 'x-api-key': this.apiKey,
158
+ },
159
+ body: JSON.stringify(request),
160
+ });
161
+ if (!response.ok) {
162
+ const body = await response.json().catch(() => ({}));
163
+ throw new Error(body.error || `API error: ${response.status}`);
164
+ }
165
+ return response.json();
166
+ }
167
+ isConflictError(error) {
168
+ return (error instanceof Error &&
169
+ 'documentId' in error &&
170
+ 'branchId' in error &&
171
+ typeof error.documentId === 'string' &&
172
+ typeof error.branchId === 'string');
173
+ }
174
+ }
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Moxn KB Migration Tool
4
+ *
5
+ * Import documents into Moxn Knowledge Base from various sources.
6
+ *
7
+ * Usage:
8
+ * moxn-kb-migrate local ./docs --api-key=xxx --base-path=/imported
9
+ * moxn-kb-migrate local ./docs --dry-run
10
+ *
11
+ * Environment variables:
12
+ * MOXN_API_KEY - API key for authentication
13
+ * MOXN_API_URL - API base URL (default: https://moxn.dev)
14
+ */
15
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,160 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Moxn KB Migration Tool
4
+ *
5
+ * Import documents into Moxn Knowledge Base from various sources.
6
+ *
7
+ * Usage:
8
+ * moxn-kb-migrate local ./docs --api-key=xxx --base-path=/imported
9
+ * moxn-kb-migrate local ./docs --dry-run
10
+ *
11
+ * Environment variables:
12
+ * MOXN_API_KEY - API key for authentication
13
+ * MOXN_API_URL - API base URL (default: https://moxn.dev)
14
+ */
15
+ import { Command } from 'commander';
16
+ import { LocalSource } from './sources/local.js';
17
+ import { MoxnClient } from './client.js';
18
+ const DEFAULT_API_URL = 'https://moxn.dev';
19
+ const DEFAULT_EXTENSIONS = ['.md', '.txt'];
20
+ async function runMigration(source, options) {
21
+ const startTime = Date.now();
22
+ const results = [];
23
+ // Validate source
24
+ await source.validate();
25
+ // Get document count for progress
26
+ const totalCount = await source.getDocumentCount();
27
+ if (totalCount !== undefined) {
28
+ console.log(`Found ${totalCount} documents to migrate`);
29
+ }
30
+ // Create client
31
+ const client = new MoxnClient(options);
32
+ // Process documents
33
+ let processed = 0;
34
+ for await (const doc of source.extract()) {
35
+ processed++;
36
+ const progress = totalCount ? ` (${processed}/${totalCount})` : '';
37
+ console.log(`Processing: ${doc.sourcePath}${progress}`);
38
+ const result = await client.migrateDocument(doc, options.basePath, options.onConflict, options.dryRun);
39
+ results.push(result);
40
+ // Log result
41
+ const statusIcon = {
42
+ created: '\u2713',
43
+ updated: '\u21bb',
44
+ skipped: '-',
45
+ failed: '\u2717',
46
+ }[result.status];
47
+ console.log(` ${statusIcon} ${result.status}: ${result.documentPath}`);
48
+ if (result.error) {
49
+ console.log(` Error: ${result.error}`);
50
+ }
51
+ }
52
+ // Build summary
53
+ const summary = {
54
+ total: results.length,
55
+ created: results.filter((r) => r.status === 'created').length,
56
+ updated: results.filter((r) => r.status === 'updated').length,
57
+ skipped: results.filter((r) => r.status === 'skipped').length,
58
+ failed: results.filter((r) => r.status === 'failed').length,
59
+ duration: Date.now() - startTime,
60
+ };
61
+ const log = {
62
+ timestamp: new Date().toISOString(),
63
+ source: {
64
+ type: source.sourceType,
65
+ location: source.sourceLocation,
66
+ },
67
+ targetApi: options.apiUrl,
68
+ basePath: options.basePath,
69
+ options: {
70
+ dryRun: options.dryRun,
71
+ onConflict: options.onConflict,
72
+ },
73
+ results,
74
+ summary,
75
+ };
76
+ return log;
77
+ }
78
+ function printSummary(log) {
79
+ console.log('\n--- Migration Summary ---');
80
+ console.log(`Source: ${log.source.type} (${log.source.location})`);
81
+ console.log(`Target: ${log.targetApi}`);
82
+ console.log(`Base path: ${log.basePath}`);
83
+ console.log(`Duration: ${(log.summary.duration / 1000).toFixed(1)}s`);
84
+ console.log('');
85
+ console.log(`Total: ${log.summary.total}`);
86
+ console.log(`Created: ${log.summary.created}`);
87
+ console.log(`Updated: ${log.summary.updated}`);
88
+ console.log(`Skipped: ${log.summary.skipped}`);
89
+ console.log(`Failed: ${log.summary.failed}`);
90
+ if (log.options.dryRun) {
91
+ console.log('\n(Dry run - no changes made)');
92
+ }
93
+ }
94
+ const program = new Command();
95
+ program
96
+ .name('moxn-kb-migrate')
97
+ .description('Import documents into Moxn Knowledge Base')
98
+ .version('0.1.0');
99
+ program
100
+ .command('local <directory>')
101
+ .description('Migrate documents from local filesystem')
102
+ .option('--api-key <key>', 'API key (or set MOXN_API_KEY env var)')
103
+ .option('--api-url <url>', 'API base URL', DEFAULT_API_URL)
104
+ .option('--base-path <path>', 'Base path for imported documents', '/')
105
+ .option('--extensions <exts>', 'Comma-separated file extensions', DEFAULT_EXTENSIONS.join(','))
106
+ .option('--on-conflict <action>', 'Action on conflict: skip or update', 'skip')
107
+ .option('--default-permission <perm>', 'Default permission: edit, read, or none')
108
+ .option('--ai-access <perm>', 'AI access permission: edit, read, or none')
109
+ .option('--dry-run', 'Preview without making changes', false)
110
+ .option('--json', 'Output results as JSON', false)
111
+ .action(async (directory, opts) => {
112
+ const apiKey = opts.apiKey || process.env.MOXN_API_KEY;
113
+ if (!apiKey) {
114
+ console.error('Error: API key required. Use --api-key or set MOXN_API_KEY env var.');
115
+ process.exit(1);
116
+ }
117
+ const extensions = opts.extensions.split(',').map((e) => e.trim());
118
+ const onConflict = opts.onConflict;
119
+ if (!['skip', 'update'].includes(onConflict)) {
120
+ console.error('Error: --on-conflict must be "skip" or "update"');
121
+ process.exit(1);
122
+ }
123
+ const source = new LocalSource({
124
+ directory,
125
+ extensions,
126
+ });
127
+ const migrationOptions = {
128
+ apiUrl: opts.apiUrl,
129
+ apiKey,
130
+ basePath: opts.basePath,
131
+ onConflict,
132
+ dryRun: opts.dryRun,
133
+ defaultPermission: opts.defaultPermission,
134
+ aiAccess: opts.aiAccess,
135
+ };
136
+ try {
137
+ const log = await runMigration(source, migrationOptions);
138
+ if (opts.json) {
139
+ console.log(JSON.stringify(log, null, 2));
140
+ }
141
+ else {
142
+ printSummary(log);
143
+ }
144
+ // Exit with error code if any failures
145
+ if (log.summary.failed > 0) {
146
+ process.exit(1);
147
+ }
148
+ }
149
+ catch (error) {
150
+ console.error('Migration failed:', error instanceof Error ? error.message : error);
151
+ process.exit(1);
152
+ }
153
+ });
154
+ // Future: Add notion and google-docs commands here
155
+ // program
156
+ // .command('notion')
157
+ // .description('Migrate documents from Notion')
158
+ // .option('--token <token>', 'Notion integration token')
159
+ // .action(...)
160
+ program.parse();
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Base interface for migration sources
3
+ *
4
+ * Each source (local files, Notion, Google Docs) implements this interface
5
+ * to provide a consistent way to extract documents for migration.
6
+ */
7
+ import type { ExtractedDocument } from '../types.js';
8
+ /**
9
+ * Configuration options specific to a source type
10
+ */
11
+ export interface SourceConfig {
12
+ [key: string]: unknown;
13
+ }
14
+ /**
15
+ * Abstract base class for migration sources
16
+ */
17
+ export declare abstract class MigrationSource<TConfig extends SourceConfig = SourceConfig> {
18
+ protected config: TConfig;
19
+ constructor(config: TConfig);
20
+ /**
21
+ * Human-readable name of the source type
22
+ */
23
+ abstract get sourceType(): string;
24
+ /**
25
+ * Human-readable location identifier for logging
26
+ */
27
+ abstract get sourceLocation(): string;
28
+ /**
29
+ * Validate the source configuration and connectivity
30
+ * @throws Error if configuration is invalid or source is not accessible
31
+ */
32
+ abstract validate(): Promise<void>;
33
+ /**
34
+ * Extract all documents from the source
35
+ * @yields ExtractedDocument for each document found
36
+ */
37
+ abstract extract(): AsyncGenerator<ExtractedDocument, void, unknown>;
38
+ /**
39
+ * Get the total count of documents (if known)
40
+ * Returns undefined if count cannot be determined without full extraction
41
+ */
42
+ abstract getDocumentCount(): Promise<number | undefined>;
43
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Base interface for migration sources
3
+ *
4
+ * Each source (local files, Notion, Google Docs) implements this interface
5
+ * to provide a consistent way to extract documents for migration.
6
+ */
7
+ /**
8
+ * Abstract base class for migration sources
9
+ */
10
+ export class MigrationSource {
11
+ config;
12
+ constructor(config) {
13
+ this.config = config;
14
+ }
15
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Source registry and factory
3
+ */
4
+ export { MigrationSource, type SourceConfig } from './base.js';
5
+ export { LocalSource, type LocalSourceConfig } from './local.js';
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Source registry and factory
3
+ */
4
+ export { MigrationSource } from './base.js';
5
+ export { LocalSource } from './local.js';
6
+ // Future sources:
7
+ // export { NotionSource, type NotionSourceConfig } from './notion.js';
8
+ // export { GoogleDocsSource, type GoogleDocsSourceConfig } from './google-docs.js';
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Local filesystem source for migration
3
+ *
4
+ * Extracts documents from local markdown and text files.
5
+ */
6
+ import { MigrationSource, type SourceConfig } from './base.js';
7
+ import type { ExtractedDocument } from '../types.js';
8
+ export interface LocalSourceConfig extends SourceConfig {
9
+ /** Directory path to scan for documents */
10
+ directory: string;
11
+ /** File extensions to include (default: ['.md', '.txt']) */
12
+ extensions: string[];
13
+ }
14
+ /**
15
+ * Local filesystem migration source
16
+ */
17
+ export declare class LocalSource extends MigrationSource<LocalSourceConfig> {
18
+ private files;
19
+ get sourceType(): string;
20
+ get sourceLocation(): string;
21
+ validate(): Promise<void>;
22
+ getDocumentCount(): Promise<number | undefined>;
23
+ extract(): AsyncGenerator<ExtractedDocument, void, unknown>;
24
+ private discoverFiles;
25
+ private extractDocument;
26
+ private parseMarkdownSections;
27
+ private nodesToContentBlocks;
28
+ private imageToBlock;
29
+ private guessImageType;
30
+ private extensionToMediaType;
31
+ private extractText;
32
+ private nodeToMarkdown;
33
+ private tableToMarkdown;
34
+ }
@@ -0,0 +1,301 @@
1
+ /**
2
+ * Local filesystem source for migration
3
+ *
4
+ * Extracts documents from local markdown and text files.
5
+ */
6
+ import * as fs from 'fs/promises';
7
+ import * as path from 'path';
8
+ import { glob } from 'glob';
9
+ import { unified } from 'unified';
10
+ import remarkParse from 'remark-parse';
11
+ import { MigrationSource } from './base.js';
12
+ /**
13
+ * Local filesystem migration source
14
+ */
15
+ export class LocalSource extends MigrationSource {
16
+ files = null;
17
+ get sourceType() {
18
+ return 'local';
19
+ }
20
+ get sourceLocation() {
21
+ return this.config.directory;
22
+ }
23
+ async validate() {
24
+ const stats = await fs.stat(this.config.directory);
25
+ if (!stats.isDirectory()) {
26
+ throw new Error(`Not a directory: ${this.config.directory}`);
27
+ }
28
+ }
29
+ async getDocumentCount() {
30
+ if (!this.files) {
31
+ await this.discoverFiles();
32
+ }
33
+ return this.files.length;
34
+ }
35
+ async *extract() {
36
+ if (!this.files) {
37
+ await this.discoverFiles();
38
+ }
39
+ for (const file of this.files) {
40
+ const doc = await this.extractDocument(file);
41
+ if (doc) {
42
+ yield doc;
43
+ }
44
+ }
45
+ }
46
+ async discoverFiles() {
47
+ const patterns = this.config.extensions.map((ext) => `**/*${ext.startsWith('.') ? ext : '.' + ext}`);
48
+ const allFiles = [];
49
+ for (const pattern of patterns) {
50
+ const matches = await glob(pattern, {
51
+ cwd: this.config.directory,
52
+ nodir: true,
53
+ ignore: ['**/node_modules/**', '**/.git/**'],
54
+ });
55
+ allFiles.push(...matches);
56
+ }
57
+ // Deduplicate and sort
58
+ this.files = [...new Set(allFiles)].sort();
59
+ }
60
+ async extractDocument(relativePath) {
61
+ const fullPath = path.join(this.config.directory, relativePath);
62
+ const content = await fs.readFile(fullPath, 'utf-8');
63
+ // Parse relative path to create document path
64
+ const parsed = path.parse(relativePath);
65
+ const dirParts = parsed.dir ? parsed.dir.split(path.sep) : [];
66
+ const docPath = [...dirParts, parsed.name].join('/');
67
+ // Derive name from filename
68
+ const name = parsed.name
69
+ .split(/[-_]/)
70
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
71
+ .join(' ');
72
+ // Parse into sections
73
+ const sections = this.parseMarkdownSections(content, path.dirname(fullPath));
74
+ if (sections.length === 0) {
75
+ return null;
76
+ }
77
+ return {
78
+ relativePath: docPath,
79
+ name,
80
+ sections,
81
+ sourcePath: relativePath,
82
+ };
83
+ }
84
+ parseMarkdownSections(content, baseDir) {
85
+ const tree = unified().use(remarkParse).parse(content);
86
+ const sections = [];
87
+ let currentSection = null;
88
+ let pendingContent = [];
89
+ const flushPendingContent = () => {
90
+ if (pendingContent.length > 0 && currentSection) {
91
+ const blocks = this.nodesToContentBlocks(pendingContent, baseDir);
92
+ currentSection.content.push(...blocks);
93
+ pendingContent = [];
94
+ }
95
+ };
96
+ for (const node of tree.children) {
97
+ if (node.type === 'heading') {
98
+ const heading = node;
99
+ // H1 becomes document title context, H2+ become sections
100
+ if (heading.depth === 1) {
101
+ // Store H1 content as intro section if we have content before first H2
102
+ if (pendingContent.length > 0 && !currentSection) {
103
+ const introBlocks = this.nodesToContentBlocks(pendingContent, baseDir);
104
+ if (introBlocks.length > 0) {
105
+ sections.push({
106
+ name: 'Introduction',
107
+ content: introBlocks,
108
+ });
109
+ }
110
+ pendingContent = [];
111
+ }
112
+ continue;
113
+ }
114
+ // Flush previous section
115
+ flushPendingContent();
116
+ if (currentSection && currentSection.content.length > 0) {
117
+ sections.push(currentSection);
118
+ }
119
+ // Start new section
120
+ const sectionName = this.extractText(heading);
121
+ currentSection = {
122
+ name: sectionName,
123
+ content: [],
124
+ };
125
+ }
126
+ else {
127
+ pendingContent.push(node);
128
+ }
129
+ }
130
+ // Flush final section
131
+ flushPendingContent();
132
+ if (currentSection && currentSection.content.length > 0) {
133
+ sections.push(currentSection);
134
+ }
135
+ // If no sections created but we have content, create a single section
136
+ if (sections.length === 0 && tree.children.length > 0) {
137
+ const blocks = this.nodesToContentBlocks(tree.children, baseDir);
138
+ if (blocks.length > 0) {
139
+ sections.push({
140
+ name: 'Content',
141
+ content: blocks,
142
+ });
143
+ }
144
+ }
145
+ return sections;
146
+ }
147
+ nodesToContentBlocks(nodes, baseDir) {
148
+ const blocks = [];
149
+ let textBuffer = '';
150
+ const flushTextBuffer = () => {
151
+ if (textBuffer.trim()) {
152
+ blocks.push({
153
+ blockType: 'text',
154
+ text: textBuffer.trim(),
155
+ });
156
+ textBuffer = '';
157
+ }
158
+ };
159
+ for (const node of nodes) {
160
+ if (node.type === 'image') {
161
+ flushTextBuffer();
162
+ const imageBlock = this.imageToBlock(node, baseDir);
163
+ if (imageBlock) {
164
+ blocks.push(imageBlock);
165
+ }
166
+ }
167
+ else {
168
+ textBuffer += this.nodeToMarkdown(node) + '\n\n';
169
+ }
170
+ }
171
+ flushTextBuffer();
172
+ return blocks;
173
+ }
174
+ imageToBlock(node, baseDir) {
175
+ const url = node.url;
176
+ // Handle external URLs
177
+ if (url.startsWith('http://') || url.startsWith('https://')) {
178
+ return {
179
+ blockType: 'image',
180
+ type: 'url',
181
+ url,
182
+ mediaType: this.guessImageType(url),
183
+ alt: node.alt || undefined,
184
+ };
185
+ }
186
+ // Handle local files
187
+ const imagePath = path.isAbsolute(url) ? url : path.join(baseDir, url);
188
+ const ext = path.extname(imagePath).toLowerCase();
189
+ const mediaType = this.extensionToMediaType(ext);
190
+ if (!mediaType) {
191
+ return null;
192
+ }
193
+ return {
194
+ blockType: 'image',
195
+ type: 'file',
196
+ path: imagePath,
197
+ mediaType,
198
+ alt: node.alt || undefined,
199
+ };
200
+ }
201
+ guessImageType(url) {
202
+ const ext = path.extname(new URL(url).pathname).toLowerCase();
203
+ return this.extensionToMediaType(ext) || 'image/png';
204
+ }
205
+ extensionToMediaType(ext) {
206
+ const map = {
207
+ '.png': 'image/png',
208
+ '.jpg': 'image/jpeg',
209
+ '.jpeg': 'image/jpeg',
210
+ '.gif': 'image/gif',
211
+ '.webp': 'image/webp',
212
+ };
213
+ return map[ext] || null;
214
+ }
215
+ extractText(node) {
216
+ const parts = [];
217
+ for (const child of node.children) {
218
+ if ('value' in child) {
219
+ parts.push(child.value);
220
+ }
221
+ else if ('children' in child) {
222
+ parts.push(this.extractText(child));
223
+ }
224
+ }
225
+ return parts.join('');
226
+ }
227
+ nodeToMarkdown(node) {
228
+ // Simple markdown serialization - could use remark-stringify for more complete solution
229
+ switch (node.type) {
230
+ case 'paragraph':
231
+ return node.children
232
+ .map((c) => this.nodeToMarkdown(c))
233
+ .join('');
234
+ case 'text':
235
+ return node.value;
236
+ case 'strong':
237
+ return `**${node.children.map((c) => this.nodeToMarkdown(c)).join('')}**`;
238
+ case 'emphasis':
239
+ return `*${node.children.map((c) => this.nodeToMarkdown(c)).join('')}*`;
240
+ case 'inlineCode':
241
+ return `\`${node.value}\``;
242
+ case 'code':
243
+ const code = node;
244
+ return `\`\`\`${code.lang || ''}\n${code.value}\n\`\`\``;
245
+ case 'link':
246
+ const link = node;
247
+ const linkText = link.children.map((c) => this.nodeToMarkdown(c)).join('');
248
+ return `[${linkText}](${link.url})`;
249
+ case 'list':
250
+ const list = node;
251
+ return list.children
252
+ .map((item, i) => {
253
+ const prefix = list.ordered ? `${i + 1}. ` : '- ';
254
+ const content = item.children
255
+ .map((c) => this.nodeToMarkdown(c))
256
+ .join('');
257
+ return prefix + content;
258
+ })
259
+ .join('\n');
260
+ case 'listItem':
261
+ return node.children
262
+ .map((c) => this.nodeToMarkdown(c))
263
+ .join('');
264
+ case 'blockquote':
265
+ const quoteContent = node.children
266
+ .map((c) => this.nodeToMarkdown(c))
267
+ .join('\n');
268
+ return quoteContent
269
+ .split('\n')
270
+ .map((line) => `> ${line}`)
271
+ .join('\n');
272
+ case 'heading':
273
+ const heading = node;
274
+ const hashes = '#'.repeat(heading.depth);
275
+ const headingText = heading.children.map((c) => this.nodeToMarkdown(c)).join('');
276
+ return `${hashes} ${headingText}`;
277
+ case 'thematicBreak':
278
+ return '---';
279
+ case 'table':
280
+ return this.tableToMarkdown(node);
281
+ default:
282
+ return '';
283
+ }
284
+ }
285
+ tableToMarkdown(node) {
286
+ if (!node.children || node.children.length === 0)
287
+ return '';
288
+ const rows = node.children.map((row) => row.children.map((cell) => cell.children.map((c) => this.nodeToMarkdown(c)).join('')));
289
+ if (rows.length === 0)
290
+ return '';
291
+ const header = rows[0];
292
+ const separator = header.map(() => '---');
293
+ const body = rows.slice(1);
294
+ const lines = [
295
+ '| ' + header.join(' | ') + ' |',
296
+ '| ' + separator.join(' | ') + ' |',
297
+ ...body.map((row) => '| ' + row.join(' | ') + ' |'),
298
+ ];
299
+ return lines.join('\n');
300
+ }
301
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Types for the KB migration tool
3
+ */
4
+ /**
5
+ * A content block in a section
6
+ */
7
+ export interface ContentBlock {
8
+ blockType: 'text' | 'image' | 'document';
9
+ text?: string;
10
+ type?: 'base64' | 'url' | 'file';
11
+ mediaType?: string;
12
+ path?: string;
13
+ url?: string;
14
+ base64?: string;
15
+ alt?: string;
16
+ filename?: string;
17
+ }
18
+ /**
19
+ * A section to be created in a document
20
+ */
21
+ export interface SectionInput {
22
+ name: string;
23
+ content: ContentBlock[];
24
+ }
25
+ /**
26
+ * A document extracted from a source
27
+ */
28
+ export interface ExtractedDocument {
29
+ /** Relative path from source root (e.g., "guides/getting-started") */
30
+ relativePath: string;
31
+ /** Document name (derived from filename or metadata) */
32
+ name: string;
33
+ /** Optional description */
34
+ description?: string;
35
+ /** Sections extracted from the document */
36
+ sections: SectionInput[];
37
+ /** Original source path/identifier for logging */
38
+ sourcePath: string;
39
+ }
40
+ /**
41
+ * Status of a single document migration
42
+ */
43
+ export type MigrationStatus = 'created' | 'updated' | 'skipped' | 'failed';
44
+ /**
45
+ * Result of migrating a single document
46
+ */
47
+ export interface MigrationResult {
48
+ sourcePath: string;
49
+ documentPath: string;
50
+ status: MigrationStatus;
51
+ documentId?: string;
52
+ branchId?: string;
53
+ sectionsCount?: number;
54
+ error?: string;
55
+ duration?: number;
56
+ }
57
+ /**
58
+ * Complete migration log for JSON output
59
+ */
60
+ export interface MigrationLog {
61
+ timestamp: string;
62
+ source: {
63
+ type: string;
64
+ location: string;
65
+ };
66
+ targetApi: string;
67
+ basePath: string;
68
+ options: {
69
+ dryRun: boolean;
70
+ onConflict: 'skip' | 'update';
71
+ extensions?: string[];
72
+ };
73
+ results: MigrationResult[];
74
+ summary: {
75
+ total: number;
76
+ created: number;
77
+ updated: number;
78
+ skipped: number;
79
+ failed: number;
80
+ duration: number;
81
+ };
82
+ }
83
+ /**
84
+ * Options for migration
85
+ */
86
+ export interface MigrationOptions {
87
+ /** API endpoint base URL */
88
+ apiUrl: string;
89
+ /** API key for authentication */
90
+ apiKey: string;
91
+ /** Base path prefix for all documents */
92
+ basePath: string;
93
+ /** How to handle existing documents */
94
+ onConflict: 'skip' | 'update';
95
+ /** Dry run mode - don't actually create documents */
96
+ dryRun: boolean;
97
+ /** Default permission for documents */
98
+ defaultPermission?: 'edit' | 'read' | 'none';
99
+ /** AI access permission for documents */
100
+ aiAccess?: 'edit' | 'read' | 'none';
101
+ }
102
+ /**
103
+ * Error response from API when document already exists
104
+ */
105
+ export interface ConflictError {
106
+ error: string;
107
+ documentId: string;
108
+ branchId: string;
109
+ }
package/dist/types.js ADDED
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Types for the KB migration tool
3
+ */
4
+ export {};
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@moxn/kb-migrate",
3
+ "version": "0.1.0",
4
+ "description": "Migration tool for importing documents into Moxn Knowledge Base from local files, Notion, Google Docs, and more",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "bin": {
9
+ "moxn-kb-migrate": "./bin/moxn-kb-migrate"
10
+ },
11
+ "scripts": {
12
+ "build": "tsc",
13
+ "dev": "tsc --watch",
14
+ "prepublishOnly": "npm run build"
15
+ },
16
+ "dependencies": {
17
+ "commander": "^12.0.0",
18
+ "glob": "^10.0.0",
19
+ "remark-parse": "^11.0.0",
20
+ "unified": "^11.0.0"
21
+ },
22
+ "devDependencies": {
23
+ "@types/node": "^20.0.0",
24
+ "typescript": "^5.0.0"
25
+ },
26
+ "engines": {
27
+ "node": ">=18.0.0"
28
+ },
29
+ "files": [
30
+ "dist",
31
+ "bin"
32
+ ],
33
+ "keywords": [
34
+ "moxn",
35
+ "knowledge-base",
36
+ "migration",
37
+ "notion",
38
+ "google-docs",
39
+ "markdown"
40
+ ],
41
+ "repository": {
42
+ "type": "git",
43
+ "url": "https://github.com/moxn-ai/moxn.git",
44
+ "directory": "packages/kb-migrate"
45
+ },
46
+ "homepage": "https://moxn.dev",
47
+ "bugs": {
48
+ "url": "https://github.com/moxn-ai/moxn/issues"
49
+ },
50
+ "author": "Moxn <support@moxn.dev>",
51
+ "license": "MIT",
52
+ "publishConfig": {
53
+ "access": "public"
54
+ }
55
+ }