Implements Phase 1.5 (Comprehensive MIME Type Detection) and adds built-in image processing support to IntelligentImportAugmentation. **New Features:** - ImageHandler: Extracts image metadata (dimensions, format, color space) using sharp - EXIF extraction: Camera data, GPS, timestamps using exifr library - Support for JPEG, PNG, WebP, GIF, TIFF, BMP, SVG, HEIC, AVIF formats - MimeTypeDetector: Unified MIME type detection with magic byte support - FormatDetector: Enhanced with image format detection via MIME + magic bytes **Architecture Fixes:** - Fixed brain.import() augmentation pipeline integration (src/brainy.ts:3140-3154) - Added parameter spreading for ImportSource objects to enable augmentation access - Fixed metadata propagation through ImportCoordinator to final results - Added augmentation data check in ImportCoordinator.extract() **Integration:** - ImageHandler registered as built-in handler alongside CSV, Excel, PDF - Images import as 'media' entities with 'image' subtype - Full metadata preserved in knowledge graph entities - Configuration options: enableImage, extractEXIF, imageDefaults **Test Coverage:** - 15 integration tests (image-import.test.ts) - 100% passing - 27 unit tests (image-handler.test.ts) - 100% passing - Format detection tests for all supported image types - Error handling and resilience tests **Breaking Changes:** None - backward compatible Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
387 lines
No EOL
11 KiB
TypeScript
387 lines
No EOL
11 KiB
TypeScript
/**
|
|
* Directory Importer for VFS
|
|
*
|
|
* Efficiently imports real directories into VFS with:
|
|
* - Batch processing for performance
|
|
* - Progress tracking
|
|
* - Error recovery
|
|
* - Parallel processing
|
|
*/
|
|
|
|
import { promises as fs } from 'fs'
|
|
import * as path from 'path'
|
|
import { VirtualFileSystem } from '../VirtualFileSystem.js'
|
|
import { Brainy } from '../../brainy.js'
|
|
import { NounType } from '../../types/graphTypes.js'
|
|
import { v4 as uuidv4 } from '../../universal/uuid.js'
|
|
import { mimeDetector } from '../MimeTypeDetector.js'
|
|
|
|
export interface ImportOptions {
|
|
targetPath?: string // VFS target path (default: '/')
|
|
recursive?: boolean // Import subdirectories (default: true)
|
|
skipHidden?: boolean // Skip hidden files (default: false)
|
|
skipNodeModules?: boolean // Skip node_modules (default: true)
|
|
batchSize?: number // Files per batch (default: 100)
|
|
generateEmbeddings?: boolean // Generate embeddings (default: true)
|
|
extractMetadata?: boolean // Extract metadata (default: true)
|
|
showProgress?: boolean // Log progress (default: false)
|
|
filter?: (path: string) => boolean // Custom filter function
|
|
|
|
// v4.10.0: Import tracking
|
|
importId?: string // Unique import identifier (auto-generated if not provided)
|
|
projectId?: string // Project identifier grouping related imports
|
|
customMetadata?: Record<string, any> // Custom metadata to attach
|
|
}
|
|
|
|
export interface ImportResult {
|
|
imported: string[] // Successfully imported paths
|
|
failed: Array<{ // Failed imports
|
|
path: string
|
|
error: Error
|
|
}>
|
|
skipped: string[] // Skipped paths
|
|
totalSize: number // Total bytes imported
|
|
duration: number // Time taken in ms
|
|
filesProcessed: number // Total files processed
|
|
directoriesCreated: number // Total directories created
|
|
}
|
|
|
|
export interface ImportProgress {
|
|
type: 'progress' | 'complete' | 'error'
|
|
processed: number
|
|
total?: number
|
|
current?: string
|
|
error?: Error
|
|
}
|
|
|
|
export class DirectoryImporter {
|
|
constructor(
|
|
private vfs: VirtualFileSystem,
|
|
private brain: Brainy
|
|
) {}
|
|
|
|
/**
|
|
* Import a directory or file into VFS
|
|
*/
|
|
async import(sourcePath: string, options: ImportOptions = {}): Promise<ImportResult> {
|
|
const startTime = Date.now()
|
|
|
|
// v4.10.0: Generate tracking metadata
|
|
const importId = options.importId || uuidv4()
|
|
const projectId = options.projectId || this.deriveProjectId(options.targetPath || '/')
|
|
const trackingMetadata = {
|
|
importIds: [importId],
|
|
projectId,
|
|
importedAt: Date.now(),
|
|
importSource: sourcePath,
|
|
...(options.customMetadata || {})
|
|
}
|
|
|
|
// Store tracking metadata in options for use in helper methods
|
|
const enhancedOptions = { ...options, _trackingMetadata: trackingMetadata }
|
|
|
|
const result: ImportResult = {
|
|
imported: [],
|
|
failed: [],
|
|
skipped: [],
|
|
totalSize: 0,
|
|
duration: 0,
|
|
filesProcessed: 0,
|
|
directoriesCreated: 0
|
|
}
|
|
|
|
try {
|
|
const stats = await fs.stat(sourcePath)
|
|
|
|
if (stats.isFile()) {
|
|
await this.importFile(sourcePath, options.targetPath || '/', result)
|
|
} else if (stats.isDirectory()) {
|
|
await this.importDirectory(sourcePath, enhancedOptions as any, result)
|
|
}
|
|
} catch (error) {
|
|
result.failed.push({
|
|
path: sourcePath,
|
|
error: error as Error
|
|
})
|
|
}
|
|
|
|
result.duration = Date.now() - startTime
|
|
return result
|
|
}
|
|
|
|
/**
|
|
* Derive project ID from target path
|
|
*/
|
|
private deriveProjectId(targetPath: string): string {
|
|
const segments = targetPath.split('/').filter(s => s.length > 0)
|
|
return segments.length > 0 ? segments[0] : 'default_project'
|
|
}
|
|
|
|
/**
|
|
* Import with progress tracking (generator)
|
|
*/
|
|
async *importStream(sourcePath: string, options: ImportOptions = {}): AsyncGenerator<ImportProgress> {
|
|
const files = await this.collectFiles(sourcePath, options)
|
|
const total = files.length
|
|
const batchSize = options.batchSize || 100
|
|
let processed = 0
|
|
|
|
// Process in batches
|
|
for (let i = 0; i < files.length; i += batchSize) {
|
|
const batch = files.slice(i, i + batchSize)
|
|
|
|
try {
|
|
await this.processBatch(batch, options)
|
|
processed += batch.length
|
|
|
|
yield {
|
|
type: 'progress',
|
|
processed,
|
|
total,
|
|
current: batch[batch.length - 1]
|
|
}
|
|
} catch (error) {
|
|
yield {
|
|
type: 'error',
|
|
processed,
|
|
total,
|
|
error: error as Error
|
|
}
|
|
}
|
|
}
|
|
|
|
yield {
|
|
type: 'complete',
|
|
processed,
|
|
total
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Import a directory recursively
|
|
*/
|
|
private async importDirectory(
|
|
dirPath: string,
|
|
options: ImportOptions,
|
|
result: ImportResult
|
|
): Promise<void> {
|
|
const targetPath = options.targetPath || '/'
|
|
|
|
// Create VFS directory structure
|
|
await this.createDirectoryStructure(dirPath, targetPath, options, result)
|
|
|
|
// Collect all files
|
|
const files = await this.collectFiles(dirPath, options)
|
|
|
|
// Process files in batches
|
|
const batchSize = options.batchSize || 100
|
|
for (let i = 0; i < files.length; i += batchSize) {
|
|
const batch = files.slice(i, i + batchSize)
|
|
await this.processBatch(batch, options, result)
|
|
|
|
if (options.showProgress && i % (batchSize * 10) === 0) {
|
|
console.log(`Imported ${i} / ${files.length} files...`)
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create directory structure in VFS
|
|
*/
|
|
private async createDirectoryStructure(
|
|
sourcePath: string,
|
|
targetPath: string,
|
|
options: ImportOptions,
|
|
result: ImportResult
|
|
): Promise<void> {
|
|
// Walk directory tree and create all directories first
|
|
const dirsToCreate: string[] = []
|
|
|
|
const collectDirs = async (dir: string, vfsPath: string) => {
|
|
dirsToCreate.push(vfsPath)
|
|
|
|
const entries = await fs.readdir(dir, { withFileTypes: true })
|
|
for (const entry of entries) {
|
|
if (entry.isDirectory()) {
|
|
if (this.shouldSkip(entry.name, path.join(dir, entry.name), options)) {
|
|
continue
|
|
}
|
|
|
|
const childPath = path.join(dir, entry.name)
|
|
const childVfsPath = path.posix.join(vfsPath, entry.name)
|
|
|
|
if (options.recursive !== false) {
|
|
await collectDirs(childPath, childVfsPath)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
await collectDirs(sourcePath, targetPath)
|
|
|
|
// Create all directories
|
|
const trackingMetadata = (options as any)._trackingMetadata || {}
|
|
for (const dirPath of dirsToCreate) {
|
|
try {
|
|
await this.vfs.mkdir(dirPath, {
|
|
recursive: true,
|
|
metadata: trackingMetadata // v4.10.0: Add tracking metadata
|
|
})
|
|
result.directoriesCreated++
|
|
} catch (error: any) {
|
|
if (error.code !== 'EEXIST') {
|
|
result.failed.push({ path: dirPath, error })
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Collect all files to be imported
|
|
*/
|
|
private async collectFiles(dirPath: string, options: ImportOptions): Promise<string[]> {
|
|
const files: string[] = []
|
|
|
|
const walk = async (dir: string) => {
|
|
const entries = await fs.readdir(dir, { withFileTypes: true })
|
|
|
|
for (const entry of entries) {
|
|
const fullPath = path.join(dir, entry.name)
|
|
|
|
if (this.shouldSkip(entry.name, fullPath, options)) {
|
|
continue
|
|
}
|
|
|
|
if (entry.isFile()) {
|
|
files.push(fullPath)
|
|
} else if (entry.isDirectory() && options.recursive !== false) {
|
|
await walk(fullPath)
|
|
}
|
|
}
|
|
}
|
|
|
|
await walk(dirPath)
|
|
return files
|
|
}
|
|
|
|
/**
|
|
* Process a batch of files
|
|
*/
|
|
private async processBatch(
|
|
files: string[],
|
|
options: ImportOptions,
|
|
result?: ImportResult
|
|
): Promise<void> {
|
|
const imports = await Promise.allSettled(
|
|
files.map(filePath => this.importSingleFile(filePath, options))
|
|
)
|
|
|
|
if (result) {
|
|
for (let i = 0; i < imports.length; i++) {
|
|
const importResult = imports[i]
|
|
const filePath = files[i]
|
|
|
|
if (importResult.status === 'fulfilled') {
|
|
result.imported.push(importResult.value.vfsPath)
|
|
result.totalSize += importResult.value.size
|
|
result.filesProcessed++
|
|
} else {
|
|
result.failed.push({
|
|
path: filePath,
|
|
error: importResult.reason
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Import a single file
|
|
*/
|
|
private async importSingleFile(
|
|
filePath: string,
|
|
options: ImportOptions
|
|
): Promise<{ vfsPath: string, size: number }> {
|
|
const stats = await fs.stat(filePath)
|
|
const content = await fs.readFile(filePath)
|
|
|
|
// Calculate VFS path
|
|
const relativePath = path.relative(process.cwd(), filePath)
|
|
const vfsPath = path.posix.join(options.targetPath || '/', relativePath)
|
|
|
|
// Generate embedding if requested
|
|
let embedding: number[] | undefined
|
|
if (options.generateEmbeddings !== false) {
|
|
try {
|
|
// Use first 10KB for embedding
|
|
const text = content.toString('utf8', 0, Math.min(10240, content.length))
|
|
// Generate embedding using brain's embed method
|
|
const embedResult = await this.brain.embed({ data: text })
|
|
embedding = embedResult
|
|
} catch {
|
|
// Continue without embedding if generation fails
|
|
}
|
|
}
|
|
|
|
// Write to VFS
|
|
const trackingMetadata = (options as any)._trackingMetadata || {}
|
|
await this.vfs.writeFile(vfsPath, content, {
|
|
generateEmbedding: options.generateEmbeddings,
|
|
extractMetadata: options.extractMetadata,
|
|
metadata: {
|
|
originalPath: filePath,
|
|
originalSize: stats.size,
|
|
originalModified: stats.mtime.getTime(),
|
|
...trackingMetadata // v4.10.0: Add tracking metadata
|
|
}
|
|
})
|
|
|
|
return { vfsPath, size: stats.size }
|
|
}
|
|
|
|
/**
|
|
* Import a single file (for non-directory imports)
|
|
*/
|
|
private async importFile(
|
|
filePath: string,
|
|
targetPath: string,
|
|
result: ImportResult
|
|
): Promise<void> {
|
|
try {
|
|
const imported = await this.importSingleFile(filePath, { targetPath })
|
|
result.imported.push(imported.vfsPath)
|
|
result.totalSize += imported.size
|
|
result.filesProcessed++
|
|
} catch (error) {
|
|
result.failed.push({
|
|
path: filePath,
|
|
error: error as Error
|
|
})
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if a path should be skipped
|
|
*/
|
|
private shouldSkip(name: string, fullPath: string, options: ImportOptions): boolean {
|
|
// Skip hidden files if requested
|
|
if (options.skipHidden && name.startsWith('.')) {
|
|
return true
|
|
}
|
|
|
|
// Skip node_modules by default
|
|
if (name === 'node_modules' && options.skipNodeModules !== false) {
|
|
return true
|
|
}
|
|
|
|
// Apply custom filter
|
|
if (options.filter && !options.filter(fullPath)) {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// v5.2.0: MIME detection moved to MimeTypeDetector service
|
|
// Removed detectMimeType() - now using mimeDetector singleton
|
|
} |