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>
This commit is contained in:
parent
6345f87eb2
commit
1874b77896
26 changed files with 5079 additions and 434 deletions
669
docs/augmentations/EXAMPLES.md
Normal file
669
docs/augmentations/EXAMPLES.md
Normal file
|
|
@ -0,0 +1,669 @@
|
|||
# Augmentation Examples - Import, Store, Export
|
||||
|
||||
This guide shows two complete workflows:
|
||||
1. **Simple Handler** - Just import a new file type
|
||||
2. **Full Augmentation** - Import + Store + Export (premium-ready)
|
||||
|
||||
---
|
||||
|
||||
## Workflow 1: Simple Handler (Import Only)
|
||||
|
||||
**Use case:** You want to import a new file type (e.g., CAD files) into Brainy's knowledge graph.
|
||||
|
||||
### Step 1: Create the Handler
|
||||
|
||||
```typescript
|
||||
// src/handlers/CADHandler.ts
|
||||
import { BaseFormatHandler } from '@soulcraft/brainy/augmentations/intelligentImport'
|
||||
import type { FormatHandlerOptions, ProcessedData } from '@soulcraft/brainy/augmentations/intelligentImport'
|
||||
import { parseCAD } from 'cad-parser' // Your parsing library
|
||||
|
||||
export class CADHandler extends BaseFormatHandler {
|
||||
readonly format = 'cad'
|
||||
|
||||
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, ['image/vnd.dwg', 'image/vnd.dxf'])
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async process(data: Buffer | string, options: FormatHandlerOptions): Promise<ProcessedData> {
|
||||
const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data)
|
||||
|
||||
// Parse CAD file
|
||||
const cad = await parseCAD(buffer)
|
||||
|
||||
// Extract entities for knowledge graph
|
||||
const entities = []
|
||||
|
||||
// Document entity
|
||||
entities.push({
|
||||
type: 'CADDocument',
|
||||
filename: options.filename,
|
||||
units: cad.units,
|
||||
bounds: cad.bounds,
|
||||
version: cad.version
|
||||
})
|
||||
|
||||
// Layer entities
|
||||
for (const layer of cad.layers) {
|
||||
entities.push({
|
||||
type: 'CADLayer',
|
||||
name: layer.name,
|
||||
color: layer.color,
|
||||
visible: layer.visible
|
||||
})
|
||||
}
|
||||
|
||||
// Object entities
|
||||
for (const obj of cad.objects) {
|
||||
entities.push({
|
||||
type: 'CADObject',
|
||||
objectType: obj.type,
|
||||
layer: obj.layer,
|
||||
geometry: obj.geometry
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
format: 'cad',
|
||||
data: entities,
|
||||
metadata: {
|
||||
rowCount: entities.length,
|
||||
fields: ['type', 'name', 'geometry'],
|
||||
processingTime: Date.now() - startTime,
|
||||
layerCount: cad.layers.length,
|
||||
objectCount: cad.objects.length
|
||||
},
|
||||
filename: options.filename
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: Register the Handler
|
||||
|
||||
```typescript
|
||||
// src/index.ts
|
||||
import { globalHandlerRegistry } from '@soulcraft/brainy/augmentations/intelligentImport'
|
||||
import { CADHandler } from './handlers/CADHandler.js'
|
||||
|
||||
// Register handler globally
|
||||
globalHandlerRegistry.registerHandler({
|
||||
name: 'cad',
|
||||
mimeTypes: ['image/vnd.dwg', 'image/vnd.dxf'],
|
||||
extensions: ['.dwg', '.dxf', '.dwf'],
|
||||
loader: async () => new CADHandler()
|
||||
})
|
||||
```
|
||||
|
||||
### Step 3: Use It
|
||||
|
||||
```typescript
|
||||
import { Brainy } from '@soulcraft/brainy'
|
||||
|
||||
const brain = new Brainy()
|
||||
await brain.init()
|
||||
|
||||
// Import CAD file - automatically routed to CADHandler
|
||||
const result = await brain.import({
|
||||
type: 'file',
|
||||
data: cadFileBuffer,
|
||||
filename: 'floor-plan.dwg'
|
||||
})
|
||||
|
||||
console.log(`Imported ${result.entities.length} CAD entities`)
|
||||
|
||||
// Query the imported data
|
||||
const layers = await brain.find({ type: 'CADLayer' })
|
||||
const objects = await brain.find({ type: 'CADObject', layer: 'WALLS' })
|
||||
```
|
||||
|
||||
**That's it!** Simple handlers just import data. Brainy handles storage automatically.
|
||||
|
||||
---
|
||||
|
||||
## Workflow 2: Full Augmentation (Import + Store + Export)
|
||||
|
||||
**Use case:** You want a complete solution that imports project files, stores them with special logic, and exports results (e.g., React project analyzer).
|
||||
|
||||
### Step 1: Create the Augmentation
|
||||
|
||||
```typescript
|
||||
// @yourcompany/brainy-react-analyzer
|
||||
|
||||
import { BaseAugmentation, type AugmentationContext } from '@soulcraft/brainy'
|
||||
import { BaseFormatHandler } from '@soulcraft/brainy/augmentations/intelligentImport'
|
||||
import type { ProcessedData } from '@soulcraft/brainy/augmentations/intelligentImport'
|
||||
import { parse } from '@babel/parser'
|
||||
import traverse from '@babel/traverse'
|
||||
import * as t from '@babel/types'
|
||||
|
||||
/**
|
||||
* React Project Analyzer Augmentation
|
||||
*
|
||||
* Features:
|
||||
* - Import: Parse React components, extract props, hooks, imports
|
||||
* - Store: Create relationships between components
|
||||
* - Export: Generate component diagram, dependency graph
|
||||
*/
|
||||
export class ReactAnalyzerAugmentation extends BaseAugmentation {
|
||||
readonly name = 'react-analyzer'
|
||||
readonly timing = 'before' as const
|
||||
readonly operations = ['import', 'export'] as any[]
|
||||
readonly priority = 75
|
||||
|
||||
private handler: ReactComponentHandler
|
||||
|
||||
constructor(config = {}) {
|
||||
super(config)
|
||||
this.handler = new ReactComponentHandler()
|
||||
}
|
||||
|
||||
async execute<T = any>(
|
||||
operation: string,
|
||||
params: any,
|
||||
next: () => Promise<T>
|
||||
): Promise<T> {
|
||||
// IMPORT: Parse React files
|
||||
if (operation === 'import' && this.isReactFile(params)) {
|
||||
return this.handleImport(params, next)
|
||||
}
|
||||
|
||||
// EXPORT: Generate diagrams/reports
|
||||
if (operation === 'export' && params.format === 'react-diagram') {
|
||||
return this.handleExport(params, next)
|
||||
}
|
||||
|
||||
return next()
|
||||
}
|
||||
|
||||
private isReactFile(params: any): boolean {
|
||||
const filename = params.filename || ''
|
||||
return (
|
||||
(filename.endsWith('.tsx') || filename.endsWith('.jsx')) &&
|
||||
params.data?.includes('React')
|
||||
)
|
||||
}
|
||||
|
||||
private async handleImport<T>(params: any, next: () => Promise<T>): Promise<T> {
|
||||
// Parse React component
|
||||
const processed = await this.handler.process(params.data, params.options)
|
||||
|
||||
// Enrich with relationships
|
||||
params.data = processed.data
|
||||
params.metadata = {
|
||||
...params.metadata,
|
||||
reactAnalysis: processed.metadata
|
||||
}
|
||||
|
||||
// Continue to next augmentation/storage
|
||||
const result = await next()
|
||||
|
||||
// Post-process: Create component relationships
|
||||
await this.createComponentRelationships(processed, result)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private async createComponentRelationships(
|
||||
processed: ProcessedData,
|
||||
result: any
|
||||
): Promise<void> {
|
||||
const brain = this.getBrain()
|
||||
if (!brain) return
|
||||
|
||||
// Find the component entity that was created
|
||||
const component = processed.data.find(d => d.type === 'ReactComponent')
|
||||
if (!component) return
|
||||
|
||||
// Create relationships for imports
|
||||
for (const imp of component.imports || []) {
|
||||
// Find or create imported component
|
||||
const imported = await brain.findOne({
|
||||
type: 'ReactComponent',
|
||||
name: imp.name
|
||||
})
|
||||
|
||||
if (imported) {
|
||||
// Create "Imports" relationship
|
||||
await brain.createRelation({
|
||||
source: result.entities[0].id,
|
||||
verb: 'Imports',
|
||||
target: imported.id,
|
||||
metadata: {
|
||||
importPath: imp.path,
|
||||
importType: imp.type
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Create relationships for prop types
|
||||
for (const prop of component.props || []) {
|
||||
if (prop.typeRef) {
|
||||
const typeEntity = await brain.findOne({
|
||||
type: 'TypeDefinition',
|
||||
name: prop.typeRef
|
||||
})
|
||||
|
||||
if (typeEntity) {
|
||||
await brain.createRelation({
|
||||
source: result.entities[0].id,
|
||||
verb: 'UsesPropType',
|
||||
target: typeEntity.id
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async handleExport<T>(params: any, next: () => Promise<T>): Promise<T> {
|
||||
const brain = this.getBrain()
|
||||
if (!brain) return next()
|
||||
|
||||
// Query all React components
|
||||
const components = await brain.find({ type: 'ReactComponent' })
|
||||
|
||||
// Build dependency graph
|
||||
const graph = await this.buildDependencyGraph(components)
|
||||
|
||||
// Generate diagram
|
||||
const diagram = this.generateMermaidDiagram(graph)
|
||||
|
||||
return {
|
||||
format: 'react-diagram',
|
||||
diagram,
|
||||
components: components.length,
|
||||
dependencies: graph.edges.length
|
||||
} as T
|
||||
}
|
||||
|
||||
private async buildDependencyGraph(components: any[]): Promise<any> {
|
||||
const brain = this.getBrain()
|
||||
const nodes = components.map(c => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
props: c.props
|
||||
}))
|
||||
|
||||
const edges = []
|
||||
for (const component of components) {
|
||||
const imports = await brain.getRelated(component.id, 'Imports')
|
||||
for (const imp of imports) {
|
||||
edges.push({
|
||||
from: component.id,
|
||||
to: imp.id,
|
||||
type: 'imports'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { nodes, edges }
|
||||
}
|
||||
|
||||
private generateMermaidDiagram(graph: any): string {
|
||||
let mermaid = 'graph TD\n'
|
||||
|
||||
for (const node of graph.nodes) {
|
||||
mermaid += ` ${node.id}[${node.name}]\n`
|
||||
}
|
||||
|
||||
for (const edge of graph.edges) {
|
||||
mermaid += ` ${edge.from} --> ${edge.to}\n`
|
||||
}
|
||||
|
||||
return mermaid
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* React Component Handler
|
||||
*/
|
||||
class ReactComponentHandler extends BaseFormatHandler {
|
||||
readonly format = 'react'
|
||||
|
||||
canHandle(data: any): boolean {
|
||||
if (typeof data === 'object' && 'filename' in data) {
|
||||
return data.filename?.match(/\.(tsx|jsx)$/) !== null
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async process(data: Buffer | string, options: any): Promise<ProcessedData> {
|
||||
const code = Buffer.isBuffer(data) ? data.toString('utf-8') : data
|
||||
|
||||
// Parse with Babel
|
||||
const ast = parse(code, {
|
||||
sourceType: 'module',
|
||||
plugins: ['jsx', 'typescript']
|
||||
})
|
||||
|
||||
// Extract component info
|
||||
const components: any[] = []
|
||||
const imports: any[] = []
|
||||
const exports: any[] = []
|
||||
|
||||
traverse(ast, {
|
||||
// Detect function components
|
||||
FunctionDeclaration(path) {
|
||||
if (this.isReactComponent(path.node)) {
|
||||
components.push({
|
||||
type: 'ReactComponent',
|
||||
name: path.node.id?.name,
|
||||
componentType: 'function',
|
||||
props: this.extractProps(path),
|
||||
hooks: this.extractHooks(path),
|
||||
state: this.extractState(path)
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
// Detect class components
|
||||
ClassDeclaration(path) {
|
||||
if (this.isReactClassComponent(path.node)) {
|
||||
components.push({
|
||||
type: 'ReactComponent',
|
||||
name: path.node.id.name,
|
||||
componentType: 'class',
|
||||
props: this.extractClassProps(path),
|
||||
state: this.extractClassState(path),
|
||||
lifecycle: this.extractLifecycleMethods(path)
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
// Extract imports
|
||||
ImportDeclaration(path) {
|
||||
imports.push({
|
||||
type: 'Import',
|
||||
from: path.node.source.value,
|
||||
imports: path.node.specifiers.map(s => ({
|
||||
name: s.local.name,
|
||||
imported: t.isImportSpecifier(s) ? s.imported.name : null
|
||||
}))
|
||||
})
|
||||
},
|
||||
|
||||
// Extract exports
|
||||
ExportNamedDeclaration(path) {
|
||||
exports.push({
|
||||
type: 'Export',
|
||||
name: path.node.declaration?.id?.name
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Enrich components with import info
|
||||
for (const component of components) {
|
||||
component.imports = imports
|
||||
component.exports = exports.find(e => e.name === component.name)
|
||||
}
|
||||
|
||||
return {
|
||||
format: 'react',
|
||||
data: components,
|
||||
metadata: {
|
||||
rowCount: components.length,
|
||||
fields: ['type', 'name', 'props', 'hooks'],
|
||||
processingTime: Date.now() - startTime,
|
||||
componentCount: components.length,
|
||||
importCount: imports.length,
|
||||
exportCount: exports.length
|
||||
},
|
||||
filename: options.filename
|
||||
}
|
||||
}
|
||||
|
||||
private isReactComponent(node: any): boolean {
|
||||
// Check if function returns JSX
|
||||
return node.body?.body?.some(stmt =>
|
||||
t.isReturnStatement(stmt) && this.isJSX(stmt.argument)
|
||||
)
|
||||
}
|
||||
|
||||
private isJSX(node: any): boolean {
|
||||
return t.isJSXElement(node) || t.isJSXFragment(node)
|
||||
}
|
||||
|
||||
private extractProps(path: any): any[] {
|
||||
const params = path.node.params
|
||||
if (params.length === 0) return []
|
||||
|
||||
const propsParam = params[0]
|
||||
if (t.isObjectPattern(propsParam)) {
|
||||
return propsParam.properties.map(p => ({
|
||||
name: p.key.name,
|
||||
type: p.typeAnnotation?.typeAnnotation?.type
|
||||
}))
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
private extractHooks(path: any): string[] {
|
||||
const hooks: string[] = []
|
||||
|
||||
path.traverse({
|
||||
CallExpression(hookPath) {
|
||||
const callee = hookPath.node.callee
|
||||
if (t.isIdentifier(callee) && callee.name.startsWith('use')) {
|
||||
hooks.push(callee.name)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return hooks
|
||||
}
|
||||
|
||||
private extractState(path: any): any[] {
|
||||
const stateVars: any[] = []
|
||||
|
||||
path.traverse({
|
||||
CallExpression(hookPath) {
|
||||
if (
|
||||
t.isIdentifier(hookPath.node.callee) &&
|
||||
hookPath.node.callee.name === 'useState'
|
||||
) {
|
||||
const parent = hookPath.parent
|
||||
if (t.isVariableDeclarator(parent) && t.isArrayPattern(parent.id)) {
|
||||
const [stateVar] = parent.id.elements
|
||||
if (t.isIdentifier(stateVar)) {
|
||||
stateVars.push({
|
||||
name: stateVar.name,
|
||||
initialValue: hookPath.node.arguments[0]
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return stateVars
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: Package as NPM Module
|
||||
|
||||
```json
|
||||
// package.json
|
||||
{
|
||||
"name": "@yourcompany/brainy-react-analyzer",
|
||||
"version": "1.0.0",
|
||||
"description": "React project analyzer for Brainy",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs"
|
||||
}
|
||||
},
|
||||
"keywords": ["brainy", "react", "analyzer", "augmentation"],
|
||||
"peerDependencies": {
|
||||
"@soulcraft/brainy": "^5.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.23.0",
|
||||
"@babel/traverse": "^7.23.0",
|
||||
"@babel/types": "^7.23.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Use the Augmentation
|
||||
|
||||
```typescript
|
||||
// Install
|
||||
// npm install @yourcompany/brainy-react-analyzer
|
||||
|
||||
import { Brainy } from '@soulcraft/brainy'
|
||||
import { ReactAnalyzerAugmentation } from '@yourcompany/brainy-react-analyzer'
|
||||
|
||||
const brain = new Brainy()
|
||||
|
||||
// Add augmentation
|
||||
brain.addAugmentation(new ReactAnalyzerAugmentation())
|
||||
|
||||
await brain.init()
|
||||
|
||||
// Import React project
|
||||
await brain.import({
|
||||
type: 'directory',
|
||||
path: '/path/to/react-project/src',
|
||||
recursive: true
|
||||
})
|
||||
|
||||
// Query components
|
||||
const components = await brain.find({ type: 'ReactComponent' })
|
||||
console.log(`Found ${components.length} React components`)
|
||||
|
||||
// Find component dependencies
|
||||
const appComponent = await brain.findOne({ type: 'ReactComponent', name: 'App' })
|
||||
const imports = await brain.getRelated(appComponent.id, 'Imports')
|
||||
console.log(`App component imports:`, imports.map(c => c.name))
|
||||
|
||||
// Export diagram
|
||||
const diagram = await brain.export({
|
||||
format: 'react-diagram'
|
||||
})
|
||||
console.log(diagram.diagram) // Mermaid diagram
|
||||
```
|
||||
|
||||
### Step 4: Premium Licensing (Optional)
|
||||
|
||||
```typescript
|
||||
// Add license checking
|
||||
export class ReactAnalyzerAugmentation extends BaseAugmentation {
|
||||
private licenseKey?: string
|
||||
|
||||
constructor(config: { licenseKey?: string } = {}) {
|
||||
super(config)
|
||||
this.licenseKey = config.licenseKey
|
||||
}
|
||||
|
||||
async onInitialize(): Promise<void> {
|
||||
if (!this.licenseKey) {
|
||||
throw new Error('React Analyzer requires a license key. Get one at https://yourcompany.com/brainy-react')
|
||||
}
|
||||
|
||||
// Verify license
|
||||
const valid = await this.verifyLicense(this.licenseKey)
|
||||
if (!valid) {
|
||||
throw new Error('Invalid license key')
|
||||
}
|
||||
|
||||
this.log('React Analyzer initialized successfully')
|
||||
}
|
||||
|
||||
private async verifyLicense(key: string): Promise<boolean> {
|
||||
// Check with your license server
|
||||
const response = await fetch('https://api.yourcompany.com/verify', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key, product: 'brainy-react-analyzer' })
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
return data.valid
|
||||
}
|
||||
}
|
||||
|
||||
// Usage with license
|
||||
brain.addAugmentation(new ReactAnalyzerAugmentation({
|
||||
licenseKey: 'YOUR-LICENSE-KEY'
|
||||
}))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Comparison: Handler vs Augmentation
|
||||
|
||||
| Feature | Simple Handler | Full Augmentation |
|
||||
|---------|---------------|-------------------|
|
||||
| **Import** | ✅ Yes (automatic) | ✅ Yes (with custom logic) |
|
||||
| **Storage** | ✅ Automatic (Brainy core) | ✅ Custom logic + relationships |
|
||||
| **Export** | ❌ No | ✅ Yes (custom formats) |
|
||||
| **Relationships** | ❌ No | ✅ Yes (create custom relationships) |
|
||||
| **Premium licensing** | ❌ Difficult | ✅ Easy (augmentation-level) |
|
||||
| **Custom operations** | ❌ Import only | ✅ Any operation |
|
||||
| **Complexity** | Low (50-100 lines) | Medium (200-500 lines) |
|
||||
|
||||
### When to use Handler:
|
||||
- Just need to import a new file type
|
||||
- Don't need custom export
|
||||
- Don't need special relationships
|
||||
- Simple use case
|
||||
|
||||
### When to use Augmentation:
|
||||
- Need import + export workflow
|
||||
- Need custom relationship logic
|
||||
- Want premium licensing capability
|
||||
- Complex business logic
|
||||
- Multiple operations (import + export + query)
|
||||
|
||||
---
|
||||
|
||||
## More Examples
|
||||
|
||||
### Example: Python Project Analyzer
|
||||
|
||||
```typescript
|
||||
class PythonAnalyzerAugmentation extends BaseAugmentation {
|
||||
// Import Python files, extract classes/functions
|
||||
// Create relationships between modules
|
||||
// Export: Dependency diagram, call graph
|
||||
}
|
||||
```
|
||||
|
||||
### Example: Database Schema Sync
|
||||
|
||||
```typescript
|
||||
class DatabaseSyncAugmentation extends BaseAugmentation {
|
||||
// Import: Parse SQL schema
|
||||
// Store: Tables, columns, relationships
|
||||
// Export: Generate migration scripts
|
||||
}
|
||||
```
|
||||
|
||||
### Example: API Documentation Generator
|
||||
|
||||
```typescript
|
||||
class APIDocAugmentation extends BaseAugmentation {
|
||||
// Import: Parse TypeScript types
|
||||
// Store: Endpoints, parameters, responses
|
||||
// Export: OpenAPI spec, Markdown docs
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## See Also
|
||||
|
||||
- [FORMAT_HANDLERS.md](./FORMAT_HANDLERS.md) - Creating format handlers
|
||||
- [AUGMENTATIONS.md](./AUGMENTATIONS.md) - Augmentation system details
|
||||
- [ImageHandler source](../../src/augmentations/intelligentImport/handlers/imageHandler.ts) - Handler example
|
||||
- [IntelligentImportAugmentation source](../../src/augmentations/intelligentImport/IntelligentImportAugmentation.ts) - Augmentation example
|
||||
687
docs/augmentations/FORMAT_HANDLERS.md
Normal file
687
docs/augmentations/FORMAT_HANDLERS.md
Normal file
|
|
@ -0,0 +1,687 @@
|
|||
# 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
|
||||
|
||||
```typescript
|
||||
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:
|
||||
|
||||
```typescript
|
||||
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
|
||||
|
||||
```typescript
|
||||
protected getMimeType(data: Buffer | string | { filename?: string }): string
|
||||
```
|
||||
|
||||
Detects MIME type from filename or buffer. Uses Brainy's comprehensive MIME detection (2000+ types).
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
const mimeType = this.getMimeType({ filename: 'data.dwg' })
|
||||
// Returns: 'image/vnd.dwg'
|
||||
```
|
||||
|
||||
### MIME Type Matching
|
||||
|
||||
```typescript
|
||||
protected mimeTypeMatches(mimeType: string, patterns: string[]): boolean
|
||||
```
|
||||
|
||||
Checks if MIME type matches patterns. Supports wildcards (`image/*`).
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
if (this.mimeTypeMatches(mimeType, ['image/*', 'video/*'])) {
|
||||
// Handle all images and videos
|
||||
}
|
||||
```
|
||||
|
||||
### Extension Detection
|
||||
|
||||
```typescript
|
||||
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.
|
||||
|
||||
### Strategy 1: MIME Type Detection (Recommended)
|
||||
|
||||
```typescript
|
||||
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
|
||||
|
||||
```typescript
|
||||
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
|
||||
|
||||
```typescript
|
||||
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
|
||||
|
||||
```typescript
|
||||
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
|
||||
|
||||
```typescript
|
||||
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:**
|
||||
```typescript
|
||||
const startTime = Date.now()
|
||||
// ... processing ...
|
||||
metadata.processingTime = Date.now() - startTime
|
||||
```
|
||||
|
||||
2. **Include rich metadata:**
|
||||
```typescript
|
||||
metadata: {
|
||||
rowCount: entities.length,
|
||||
fields: ['type', 'name', ...],
|
||||
processingTime: 123,
|
||||
// Format-specific metadata
|
||||
layerCount: 5,
|
||||
objectCount: 150,
|
||||
version: '2.0'
|
||||
}
|
||||
```
|
||||
|
||||
3. **Handle errors gracefully:**
|
||||
```typescript
|
||||
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):**
|
||||
```typescript
|
||||
if (options.progressHooks?.onCurrentItem) {
|
||||
options.progressHooks.onCurrentItem(`Processing layer ${i}/${total}`)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## FormatHandlerRegistry
|
||||
|
||||
### Global Registry
|
||||
|
||||
Use the global registry for application-wide handlers:
|
||||
|
||||
```typescript
|
||||
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:
|
||||
|
||||
```typescript
|
||||
import { FormatHandlerRegistry } from '@soulcraft/brainy/augmentations/intelligentImport'
|
||||
|
||||
const registry = new FormatHandlerRegistry()
|
||||
registry.registerHandler({ ... })
|
||||
```
|
||||
|
||||
### Lazy Loading
|
||||
|
||||
Handlers are lazy-loaded for performance:
|
||||
|
||||
```typescript
|
||||
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
|
||||
|
||||
```typescript
|
||||
// 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
|
||||
|
||||
```typescript
|
||||
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
|
||||
|
||||
```typescript
|
||||
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
|
||||
|
||||
```typescript
|
||||
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:
|
||||
|
||||
```typescript
|
||||
// @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
|
||||
|
||||
```typescript
|
||||
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
|
||||
|
||||
- [ImageHandler source](../../src/augmentations/intelligentImport/handlers/imageHandler.ts) - Reference implementation
|
||||
- [BaseFormatHandler source](../../src/augmentations/intelligentImport/handlers/base.ts) - Base class
|
||||
- [FormatHandlerRegistry source](../../src/augmentations/intelligentImport/FormatHandlerRegistry.ts) - Registry implementation
|
||||
- [Augmentations Guide](./AUGMENTATIONS.md) - Creating augmentations
|
||||
Loading…
Add table
Add a link
Reference in a new issue