Skip to content

feat: plugin exporter system — file-based discovery + ENABLED_EXPORTERS #28

@simwai

Description

@simwai

Problem

The export format (Markdown) is hardcoded into FileWriter. There is clear user appetite for alternative formats (structured JSON, CSV, custom archive formats) but no extension point — any addition requires touching core code.

Proposed Solution

A file-based exporter plugin system with env-var activation. Drop a .ts file into src/exporters/, add its name to ENABLED_EXPORTERS — that's the entire integration surface.

Interface

// src/exporters/exporter.interface.ts
export interface ConversationExporter {
  /** Must match exactly what you put in ENABLED_EXPORTERS */
  name: string
  fileExtension: string
  /** Where to write output files. Return config.exportDir as the safe default. */
  outputDir(config: Config): string
  /** Serialize the conversation. Return a string (UTF-8). */
  export(conversation: ExtractedConversation): string
}

Registration & Discovery

At startup, FileWriter scans src/exporters/*.ts and auto-registers any file that default-exports a ConversationExporter. No manual imports needed.

Activation via env var

# .env.example
# Comma-separated list of exporter names to enable.
# Names must match the `name` field in each exporter file.
# Default: markdown
ENABLED_EXPORTERS=markdown
  • Default is markdown only — fully backwards compatible, no surprise output directories
  • If ENABLED_EXPORTERS is unset, falls back to markdown
  • Multiple exporters: ENABLED_EXPORTERS=markdown,csv

File Structure

src/exporters/
  exporter.interface.ts          ← the interface definition
  markdown.exporter.ts           ← built-in default (current Markdown logic moved here)
  custom.exporter.ts.example     ← copy-paste starting point (see below)

Example File (custom.exporter.ts.example)

Fully commented, immediately runnable CSV example:

// Copy this file to src/exporters/csv.exporter.ts
// Then add "csv" to ENABLED_EXPORTERS in your .env
//
// The `name` field here MUST match what you put in ENABLED_EXPORTERS.

import type { ConversationExporter } from './exporter.interface.js'
import type { ExtractedConversation } from '../scraper/conversation-extractor.js'
import type { Config } from '../utils/config.js'

const exporter: ConversationExporter = {
  name: 'csv',
  fileExtension: '.csv',

  // outputDir: where your files will be written.
  // Return config.exportDir to share the default exports folder,
  // or return a custom path for a separate output directory.
  outputDir(config: Config): string {
    return config.exportDir
  },

  // export: receives the fully extracted conversation, returns a string.
  // The string will be written to: outputDir / spaceName / title (id).csv
  export(conversation: ExtractedConversation): string {
    const header = 'role,content'
    const rows = conversation.messages.map(
      (m) => `${m.role},"${m.content.replace(/"/g, '""')}"`
    )
    return [header, ...rows].join('\n')
  },
}

export default exporter

FileWriter changes

  • Iterates registered + active exporters, calls each export(), writes to outputDir / spaceName / filename
  • Integrity check runs per exporter (file exists, non-empty)
  • Hardcoded Markdown path logic removed from FileWriter core

Documentation

Add ## Exporters section to README covering:

  • Where to drop the file (src/exporters/)
  • What each interface method does
  • That custom.exporter.ts.example exists and how to activate it
  • That outputDir returning config.exportDir is the safe default
  • That the name field must exactly match ENABLED_EXPORTERS — this is the only "magic" in the system

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions