brainy/docs/augmentations/FORMAT_HANDLERS.md
David Snelling 1874b77896 feat: add ImageHandler with EXIF extraction and comprehensive MIME detection (v5.2.0)
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>
2025-11-03 14:06:17 -08:00

16 KiB

Creating Custom Format Handlers

Version: 5.2.0+

Format handlers enable you to import ANY file type into Brainy as structured knowledge graph data. This guide shows how to create custom format handlers for your specific file formats.


Quick Start

import { BaseFormatHandler, globalHandlerRegistry } from '@soulcraft/brainy/augmentations/intelligentImport'
import type { FormatHandlerOptions, ProcessedData } from '@soulcraft/brainy/augmentations/intelligentImport'

class MyFormatHandler extends BaseFormatHandler {
  readonly format = 'myformat'

  canHandle(data: Buffer | string | { filename?: string; ext?: string }): boolean {
    // Option 1: Check by MIME type
    if (typeof data === 'object' && 'filename' in data) {
      const mimeType = this.getMimeType(data)
      return this.mimeTypeMatches(mimeType, ['application/x-myformat'])
    }

    // Option 2: Check by magic bytes
    if (Buffer.isBuffer(data)) {
      return data[0] === 0x4D && data[1] === 0x59 // "MY" magic bytes
    }

    return false
  }

  async process(
    data: Buffer | string,
    options: FormatHandlerOptions
  ): Promise<ProcessedData> {
    // Convert to Buffer
    const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data)

    // Parse your format
    const parsed = this.parseMyFormat(buffer)

    // Return structured data
    return {
      format: 'myformat',
      data: [
        {
          type: 'MyEntity',
          name: parsed.name,
          metadata: parsed.metadata
        }
      ],
      metadata: {
        rowCount: 1,
        fields: ['type', 'name', 'metadata'],
        processingTime: Date.now() - startTime
      }
    }
  }

  private parseMyFormat(buffer: Buffer): any {
    // Your parsing logic here
    return { name: 'example', metadata: {} }
  }
}

// Register globally
globalHandlerRegistry.registerHandler({
  name: 'myformat',
  mimeTypes: ['application/x-myformat'],
  extensions: ['.myf', '.myfmt'],
  loader: async () => new MyFormatHandler()
})

Now Brainy automatically handles your format:

await brain.import({
  type: 'file',
  data: myFormatBuffer,
  filename: 'data.myf'
})
// Automatically routes to MyFormatHandler!

BaseFormatHandler

All format handlers should extend BaseFormatHandler, which provides:

MIME Type Detection

protected getMimeType(data: Buffer | string | { filename?: string }): string

Detects MIME type from filename or buffer. Uses Brainy's comprehensive MIME detection (2000+ types).

Example:

const mimeType = this.getMimeType({ filename: 'data.dwg' })
// Returns: 'image/vnd.dwg'

MIME Type Matching

protected mimeTypeMatches(mimeType: string, patterns: string[]): boolean

Checks if MIME type matches patterns. Supports wildcards (image/*).

Example:

if (this.mimeTypeMatches(mimeType, ['image/*', 'video/*'])) {
  // Handle all images and videos
}

Extension Detection

protected detectExtension(data: string | Buffer | { filename?: string; ext?: string }): string | null

Extracts file extension for fallback detection.


canHandle() Method

The canHandle() method determines if your handler can process the given data.

canHandle(data: Buffer | string | { filename?: string; ext?: string }): boolean {
  if (typeof data === 'object' && 'filename' in data) {
    const mimeType = this.getMimeType(data)
    return this.mimeTypeMatches(mimeType, [
      'application/x-myformat',
      'application/myformat'
    ])
  }
  return false
}

Pros: Automatic, comprehensive, works with 2000+ types Cons: Requires filename

Strategy 2: Magic Byte Detection

canHandle(data: Buffer | string | { filename?: string; ext?: string }): boolean {
  if (Buffer.isBuffer(data)) {
    // Check magic bytes
    return (
      data[0] === 0x50 &&  // 'P'
      data[1] === 0x4B &&  // 'K'
      data[2] === 0x03 &&
      data[3] === 0x04     // ZIP signature
    )
  }
  return false
}

Pros: Works without filename, robust Cons: Requires knowledge of format structure

Strategy 3: Combined Approach

canHandle(data: Buffer | string | { filename?: string; ext?: string }): boolean {
  // Try MIME type first
  if (typeof data === 'object' && 'filename' in data) {
    const mimeType = this.getMimeType(data)
    if (this.mimeTypeMatches(mimeType, ['application/x-myformat'])) {
      return true
    }
  }

  // Fallback to magic bytes
  if (Buffer.isBuffer(data)) {
    return this.checkMagicBytes(data)
  }

  return false
}

Pros: Robust, works in all scenarios Cons: More complex


process() Method

The process() method extracts structured data from the file.

Return Format

interface ProcessedData {
  /** Format identifier */
  format: string

  /** Array of extracted entities */
  data: Array<Record<string, any>>

  /** Metadata about processing */
  metadata: {
    rowCount: number
    fields: string[]
    processingTime: number
    [key: string]: any
  }

  /** Original filename (optional) */
  filename?: string
}

Example: CAD File Handler

async process(data: Buffer | string, options: FormatHandlerOptions): Promise<ProcessedData> {
  const startTime = Date.now()
  const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data)

  // Parse CAD file
  const cad = await this.parseCAD(buffer)

  // Extract entities
  const entities = []

  // Main CAD document
  entities.push({
    type: 'CADDocument',
    filename: options.filename,
    units: cad.units,
    bounds: cad.bounds
  })

  // Layers
  for (const layer of cad.layers) {
    entities.push({
      type: 'CADLayer',
      name: layer.name,
      color: layer.color,
      visible: layer.visible,
      objectCount: layer.objects.length
    })
  }

  // Objects
  for (const obj of cad.objects) {
    entities.push({
      type: 'CADObject',
      objectType: obj.type,
      layer: obj.layer,
      geometry: obj.geometry,
      properties: obj.properties
    })
  }

  return {
    format: 'cad',
    data: entities,
    metadata: {
      rowCount: entities.length,
      fields: ['type', 'name', 'geometry', 'properties'],
      processingTime: Date.now() - startTime,
      layerCount: cad.layers.length,
      objectCount: cad.objects.length,
      units: cad.units
    },
    filename: options.filename
  }
}

Best Practices

  1. Always track processing time:

    const startTime = Date.now()
    // ... processing ...
    metadata.processingTime = Date.now() - startTime
    
  2. Include rich metadata:

    metadata: {
      rowCount: entities.length,
      fields: ['type', 'name', ...],
      processingTime: 123,
      // Format-specific metadata
      layerCount: 5,
      objectCount: 150,
      version: '2.0'
    }
    
  3. Handle errors gracefully:

    try {
      const parsed = this.parse(buffer)
      return { format: 'myformat', data: parsed, ... }
    } catch (error) {
      throw new Error(
        `Failed to parse myformat: ${error instanceof Error ? error.message : String(error)}`
      )
    }
    
  4. Support progress reporting (optional):

    if (options.progressHooks?.onCurrentItem) {
      options.progressHooks.onCurrentItem(`Processing layer ${i}/${total}`)
    }
    

FormatHandlerRegistry

Global Registry

Use the global registry for application-wide handlers:

import { globalHandlerRegistry } from '@soulcraft/brainy/augmentations/intelligentImport'

globalHandlerRegistry.registerHandler({
  name: 'myformat',
  mimeTypes: ['application/x-myformat'],
  extensions: ['.myf'],
  loader: async () => new MyFormatHandler()
})

Local Registry

Create a local registry for scoped handlers:

import { FormatHandlerRegistry } from '@soulcraft/brainy/augmentations/intelligentImport'

const registry = new FormatHandlerRegistry()
registry.registerHandler({ ... })

Lazy Loading

Handlers are lazy-loaded for performance:

globalHandlerRegistry.registerHandler({
  name: 'heavy',
  mimeTypes: ['application/x-heavy'],
  extensions: ['.heavy'],
  loader: async () => {
    // Only loaded when first needed
    const { HeavyHandler } = await import('./HeavyHandler.js')
    return new HeavyHandler()
  }
})

Getting Handlers

// By filename (automatic MIME detection)
const handler = await registry.getHandler('data.myf')

// By MIME type
const handler = await registry.getHandlerByMimeType('application/x-myformat')

// By extension
const handler = await registry.getHandlerByExtension('.myf')

// By name
const handler = await registry.getHandlerByName('myformat')

Real-World Examples

Example 1: Video Metadata Extractor

import { BaseFormatHandler } from '@soulcraft/brainy/augmentations/intelligentImport'
import ffmpeg from 'fluent-ffmpeg'

class VideoHandler extends BaseFormatHandler {
  readonly format = 'video'

  canHandle(data) {
    const mimeType = this.getMimeType(data)
    return this.mimeTypeMatches(mimeType, ['video/*'])
  }

  async process(data, options) {
    const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data)

    // Extract video metadata with ffmpeg
    const metadata = await this.extractVideoMetadata(buffer)

    return {
      format: 'video',
      data: [{
        type: 'Video',
        duration: metadata.duration,
        codec: metadata.codec,
        resolution: metadata.resolution,
        frameRate: metadata.frameRate,
        bitrate: metadata.bitrate,
        audioTracks: metadata.audioTracks,
        subtitles: metadata.subtitles
      }],
      metadata: {
        rowCount: 1,
        fields: ['type', 'duration', 'codec', 'resolution'],
        processingTime: metadata.processingTime
      }
    }
  }

  private async extractVideoMetadata(buffer: Buffer) {
    // Use ffmpeg to extract metadata
    return new Promise((resolve, reject) => {
      ffmpeg(buffer)
        .ffprobe((err, data) => {
          if (err) reject(err)
          else resolve(this.parseFFmpegOutput(data))
        })
    })
  }
}

Example 2: Git Repository Parser

import { BaseFormatHandler } from '@soulcraft/brainy/augmentations/intelligentImport'
import { simpleGit } from 'simple-git'

class GitRepoHandler extends BaseFormatHandler {
  readonly format = 'git-repo'

  canHandle(data) {
    // Check for .git directory
    if (typeof data === 'object' && 'filename' in data) {
      return data.filename?.includes('.git') || false
    }
    return false
  }

  async process(data, options) {
    const repoPath = options.filename || ''
    const git = simpleGit(repoPath)

    // Extract commits
    const log = await git.log()
    const commits = log.all.map(commit => ({
      type: 'GitCommit',
      hash: commit.hash,
      message: commit.message,
      author: commit.author_name,
      date: commit.date
    }))

    // Extract branches
    const branchSummary = await git.branchLocal()
    const branches = Object.keys(branchSummary.branches).map(name => ({
      type: 'GitBranch',
      name,
      current: branchSummary.current === name
    }))

    return {
      format: 'git-repo',
      data: [...commits, ...branches],
      metadata: {
        rowCount: commits.length + branches.length,
        fields: ['type', 'hash', 'message', 'author'],
        processingTime: Date.now() - startTime,
        commitCount: commits.length,
        branchCount: branches.length
      }
    }
  }
}

Example 3: Database Schema Importer

import { BaseFormatHandler } from '@soulcraft/brainy/augmentations/intelligentImport'
import { Client } from 'pg'

class PostgreSQLSchemaHandler extends BaseFormatHandler {
  readonly format = 'postgres-schema'

  canHandle(data) {
    if (typeof data === 'object' && 'filename' in data) {
      return data.filename?.endsWith('.sql') || false
    }
    return false
  }

  async process(data, options) {
    const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data)
    const sql = buffer.toString('utf-8')

    // Parse SQL or connect to database
    const schema = await this.parseSchema(sql)

    const entities = []

    // Tables
    for (const table of schema.tables) {
      entities.push({
        type: 'Table',
        name: table.name,
        schema: table.schema,
        columnCount: table.columns.length
      })

      // Columns
      for (const column of table.columns) {
        entities.push({
          type: 'Column',
          name: column.name,
          table: table.name,
          dataType: column.dataType,
          nullable: column.nullable,
          primaryKey: column.primaryKey
        })
      }
    }

    // Foreign keys
    for (const fk of schema.foreignKeys) {
      entities.push({
        type: 'ForeignKey',
        from: `${fk.fromTable}.${fk.fromColumn}`,
        to: `${fk.toTable}.${fk.toColumn}`
      })
    }

    return {
      format: 'postgres-schema',
      data: entities,
      metadata: {
        rowCount: entities.length,
        fields: ['type', 'name', 'table', 'dataType'],
        processingTime: Date.now() - startTime,
        tableCount: schema.tables.length,
        columnCount: schema.tables.reduce((sum, t) => sum + t.columns.length, 0),
        foreignKeyCount: schema.foreignKeys.length
      }
    }
  }
}

Creating Premium Augmentations

Package your handler as a premium augmentation:

// @yourcompany/brainy-cad-importer

import { BaseAugmentation } from '@soulcraft/brainy'
import { CADHandler } from './CADHandler.js'

export class CADImportAugmentation extends BaseAugmentation {
  readonly name = 'cad-import'
  readonly timing = 'before'
  readonly operations = ['import', 'importFile']

  private handler: CADHandler

  constructor(config = {}) {
    super(config)
    this.handler = new CADHandler()
  }

  async execute(operation, params, next) {
    // Check if this is a CAD file
    if (this.isCADFile(params)) {
      const processed = await this.handler.process(params.data, params.options)
      params.data = processed.data
      params.metadata = { ...params.metadata, ...processed.metadata }
    }

    return next()
  }

  private isCADFile(params: any): boolean {
    return this.handler.canHandle(params.data || params)
  }
}

// Usage:
// npm install @yourcompany/brainy-cad-importer
// brain.addAugmentation(new CADImportAugmentation())

Testing

import { describe, it, expect } from 'vitest'
import { MyFormatHandler } from './MyFormatHandler.js'

describe('MyFormatHandler', () => {
  let handler: MyFormatHandler

  beforeEach(() => {
    handler = new MyFormatHandler()
  })

  describe('canHandle', () => {
    it('should handle .myf files', () => {
      expect(handler.canHandle({ filename: 'data.myf' })).toBe(true)
    })

    it('should handle by MIME type', () => {
      expect(handler.canHandle({ filename: 'data.myformat' })).toBe(true)
    })

    it('should reject non-myformat files', () => {
      expect(handler.canHandle({ filename: 'data.txt' })).toBe(false)
    })
  })

  describe('process', () => {
    it('should extract structured data', async () => {
      const testData = Buffer.from('MY format data')

      const result = await handler.process(testData)

      expect(result.format).toBe('myformat')
      expect(result.data).toHaveLength(1)
      expect(result.metadata.processingTime).toBeGreaterThan(0)
    })

    it('should handle errors gracefully', async () => {
      const invalidData = Buffer.from('invalid')

      await expect(handler.process(invalidData)).rejects.toThrow()
    })
  })
})

Best Practices

  1. Always extend BaseFormatHandler - provides MIME detection and utilities
  2. Use MIME types for routing - automatic, comprehensive, maintainable
  3. Lazy load heavy dependencies - better performance
  4. Extract rich metadata - make data queryable in knowledge graph
  5. Handle errors gracefully - fail fast with clear error messages
  6. Test thoroughly - test canHandle() and process() with real data
  7. Document your format - explain what data is extracted and how
  8. Follow ProcessedData format - ensures compatibility with Brainy

See Also