Storyteller Suite - Import/Export System

Overview

The plugin supports:

Import System

Supported Formats

Format Extension Parser File
Microsoft Word .docx DocxParser.ts
EPUB .epub EpubParser.ts
PDF .pdf PdfParser.ts
Markdown .md, .markdown MarkdownParser.ts
HTML .html, .htm HtmlParser.ts
Rich Text Format .rtf RtfParser.ts
OpenDocument Text .odt OdtParser.ts
Fountain (Screenplay) .fountain FountainParser.ts
JSON .json JsonParser.ts
Plain Text .txt PlainTextParser.ts

Import Types (ImportTypes.ts)

interface ParsedDocument {
    metadata: DocumentMetadata;
    chapters: ParsedChapter[];
    warnings: string[];
    format: ImportFormat;
}

interface DocumentMetadata {
    title?: string;
    author?: string;
    totalWords: number;
    chapterCount: number;
    confidence: number;        // 0-100 parsing confidence
    detectionMethod: string;   // How chapters were detected
}

interface ParsedChapter {
    title: string;
    number?: number;
    content: string;
    wordCount: number;
    startLine: number;
    endLine: number;
    scenes?: ParsedScene[];    // If scene breaks detected
}

interface ParsedScene {
    title: string;
    content: string;
    wordCount: number;
}

interface ImportConfiguration {
    sourceFile: string;
    createNewStory: boolean;
    targetStoryId?: string;
    targetStoryName?: string;
    chapters: ChapterImportConfig[];
    entityExtractionEnabled: boolean;
    entityMappings: EntityMapping[];
}

interface ChapterImportConfig {
    originalTitle: string;
    targetName: string;
    targetNumber?: number;
    enabled: boolean;
    createScenes: boolean;
    scenes?: SceneImportConfig[];
}

Parser Interface

// DocumentParser interface
interface DocumentParser {
    readonly format: ImportFormat;
    readonly supportedExtensions: string[];
    canParse(file: TFile): boolean;
    parse(file: TFile): Promise<ParsedDocument>;
}

Markdown Parser Example

// MarkdownParser.ts
class MarkdownParser implements DocumentParser {
    readonly format: ImportFormat = 'markdown';
    readonly supportedExtensions = ['.md', '.markdown'];
    
    async parse(file: TFile): Promise<ParsedDocument> {
        const content = await this.vault.cachedRead(file);
        const headings = this.extractHeadings(content);
        const chapterLevel = this.determineChapterLevel(headings);
        const chapters = this.extractChapters(headings, chapterLevel, content);
        
        return {
            metadata: {
                title: this.extractDocumentTitle(headings),
                totalWords: countWords(content),
                chapterCount: chapters.length,
                confidence: chapters.length > 0 ? 85 : 50,
                detectionMethod: `Heading level ${chapterLevel}`
            },
            chapters,
            warnings: [],
            format: this.format
        };
    }
    
    private extractHeadings(content: string): Heading[] {
        const headings: Heading[] = [];
        const lines = content.split('\n');
        
        for (let i = 0; i < lines.length; i++) {
            const match = lines[i].match(/^(#{1,6})\s+(.+)$/);
            if (match) {
                headings.push({
                    level: match[1].length,
                    text: match[2].trim(),
                    lineIndex: i
                });
            }
        }
        
        return headings;
    }
    
    private detectScenes(content: string): ParsedScene[] {
        // Scene break patterns
        const patterns = [
            /^\*\s*\*\s*\*$/m,           // * * *
            /^-\s*-\s*-$/m,              // - - -
            /^#{3,}$/m,                   // ###
            /^---+$/m,                    // ---
        ];
        
        // Split by scene breaks
        // ...
    }
}

Import Manager (ImportManager.ts)

class ImportManager {
    private parsers: Map<ImportFormat, DocumentParser>;
    
    constructor(plugin: StorytellerSuitePlugin) {
        this.parsers = new Map([
            ['markdown', new MarkdownParser(plugin)],
            ['docx', new DocxParser(plugin)],
            ['epub', new EpubParser(plugin)],
            // ... other parsers
        ]);
    }
    
    async parseFile(file: TFile): Promise<ParsedDocument> {
        const parser = this.getParserForFile(file);
        if (!parser) {
            throw new Error(`No parser available for ${file.extension}`);
        }
        return parser.parse(file);
    }
    
    async executeImport(
        config: ImportConfiguration,
        onProgress?: ImportProgressCallback
    ): Promise<ImportResult> {
        // Validate configuration
        const validation = this.validateImport(config);
        if (!validation.isValid) {
            return { success: false, error: validation.errors.join('\n'), ... };
        }
        
        // Create/get story
        const storyId = config.createNewStory
            ? await this.createStory(config.targetStoryName!)
            : config.targetStoryId;
        
        // Extract entities if enabled
        if (config.entityExtractionEnabled) {
            await this.createEntitiesFromMappings(config);
        }
        
        // Import chapters
        const result = await this.importChapters(storyId, config, onProgress);
        
        return result;
    }
    
    private async importChapters(
        storyId: string,
        config: ImportConfiguration,
        onProgress?: ImportProgressCallback
    ): Promise<ImportResult> {
        const chaptersCreated: Chapter[] = [];
        const scenesCreated: Scene[] = [];
        
        for (const chapterConfig of config.chapters.filter(c => c.enabled)) {
            onProgress?.({
                currentStep: 'Creating chapters...',
                currentItem: chapterConfig.targetName,
                processed: chaptersCreated.length,
                total: config.chapters.filter(c => c.enabled).length,
                percentage: (chaptersCreated.length / config.chapters.length) * 100
            });
            
            // Create chapter
            const chapter: Chapter = {
                name: chapterConfig.targetName,
                number: chapterConfig.targetNumber,
                summary: '',
            };
            await this.plugin.saveChapter(chapter);
            chaptersCreated.push(chapter);
            
            // Create scenes if enabled
            if (chapterConfig.createScenes && chapterConfig.scenes) {
                for (const sceneConfig of chapterConfig.scenes) {
                    const scene: Scene = {
                        name: sceneConfig.targetName,
                        chapterId: chapter.id,
                        content: sceneConfig.content,
                        status: 'draft',
                    };
                    await this.plugin.saveScene(scene);
                    scenesCreated.push(scene);
                }
            }
        }
        
        return {
            success: true,
            chaptersCreated,
            scenesCreated,
            stats: { ... }
        };
    }
}

Entity Extraction (EntityExtractor.ts)

class EntityExtractor {
    async extractEntities(content: string): Promise<ExtractedEntities> {
        const characters = this.extractCharacters(content);
        const locations = this.extractLocations(content);
        
        return { characters, locations };
    }
    
    private extractCharacters(content: string): ExtractedEntity[] {
        const characters: ExtractedEntity[] = [];
        
        // Proper nouns followed by verbs (likely characters)
        const namePattern = /\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+)?)\b(?=\s+(?:said|asked|replied|thought|walked|ran))/g;
        
        const matches = content.matchAll(namePattern);
        const nameCounts = new Map<string, number>();
        
        for (const match of matches) {
            const name = match[1];
            nameCounts.set(name, (nameCounts.get(name) || 0) + 1);
        }
        
        for (const [name, count] of nameCounts) {
            if (count >= 2) {  // Must appear at least twice
                characters.push({
                    name,
                    type: 'character',
                    occurrences: count,
                    confidence: count >= 5 ? 'high' : count >= 3 ? 'medium' : 'low',
                    contexts: this.getContexts(content, name)
                });
            }
        }
        
        return characters;
    }
    
    private extractLocations(content: string): ExtractedEntity[] {
        // Location indicators
        const patterns = [
            /(?:in|at|to|from)\s+(?:the\s+)?([A-Z][a-z]+(?:\s+[A-Z][a-z]+)*)/g,
            /(?:entered|left|arrived at)\s+(?:the\s+)?([A-Z][a-z]+(?:\s+[A-Z][a-z]+)*)/g,
        ];
        // ...
    }
}

Import Config Modal (ImportConfigModal.ts)

Multi-step wizard:

class ImportConfigModal extends ResponsiveModal {
    private currentStep: 'select' | 'preview' | 'entities' | 'configure' | 'import';
    private configuration: ImportConfiguration;
    
    onOpen(): void {
        this.renderStep();
    }
    
    private renderStep(): void {
        switch (this.currentStep) {
            case 'select':
                this.renderSelectStep();    // Choose file
                break;
            case 'preview':
                this.renderPreviewStep();   // Preview detected chapters
                break;
            case 'entities':
                this.renderEntitiesStep();  // Entity extraction options
                break;
            case 'configure':
                this.renderConfigureStep(); // Configure chapter names/numbers
                break;
            case 'import':
                this.renderImportStep();    // Execute import with progress
                break;
        }
    }
}

Compile/Export System

Compile Engine (CompileEngine.ts)

Customizable manuscript compilation:

interface CompileWorkflow {
    id: string;
    name: string;
    description: string;
    steps: CompileStep[];
}

interface CompileStep {
    id: string;
    stepType: CompileStepType;
    enabled: boolean;
    options: Record<string, any>;
}

type CompileStepType =
    | 'strip-frontmatter'
    | 'prepend-scene-title'
    | 'remove-wikilinks'
    | 'clean-content'
    | 'extract-content-section'
    | 'concatenate'
    | 'concatenate-by-chapter'
    | 'export-markdown'
    | 'export-html'
    | 'export-docx';

Preset Workflows

// Default workflow
const defaultWorkflow: CompileWorkflow = {
    id: 'default-workflow',
    name: 'Standard Export',
    description: 'Basic export with scene titles',
    steps: [
        { stepType: 'strip-frontmatter', enabled: true },
        { stepType: 'prepend-scene-title', enabled: true, options: { format: '## $1' } },
        { stepType: 'remove-wikilinks', enabled: true, options: { keepLinkText: true } },
        { stepType: 'concatenate', enabled: true, options: { separator: '\n\n---\n\n' } },
        { stepType: 'export-markdown', enabled: true, options: { outputPath: 'manuscript.md' } }
    ]
};

// Chapter-only workflow
const chapterOnlyWorkflow: CompileWorkflow = {
    id: 'chapter-only-workflow',
    name: 'Chapter Only',
    description: 'Exports with chapter headers only - no scene titles',
    steps: [
        { stepType: 'strip-frontmatter', enabled: true },
        { stepType: 'clean-content', enabled: true, options: { 
            removeCallouts: true, removeCodeBlocks: true, removeTags: true 
        }},
        { stepType: 'remove-wikilinks', enabled: true },
        { stepType: 'concatenate-by-chapter', enabled: true, options: {
            chapterFormat: '# Chapter $number: $name',
            numberStyle: 'arabic',
            sceneSeparator: '\n\n',
            chapterSeparator: '\n\n---\n\n'
        }},
        { stepType: 'export-markdown', enabled: true }
    ]
};

// Novel submission workflow
const novelSubmissionWorkflow: CompileWorkflow = {
    id: 'novel-submission-workflow',
    name: 'Novel (Book Format)',
    description: 'Clean book format: extracts only Content sections',
    steps: [
        { stepType: 'strip-frontmatter', enabled: true },
        { stepType: 'extract-content-section', enabled: true },  // Only ## Content
        { stepType: 'clean-content', enabled: true },
        { stepType: 'remove-wikilinks', enabled: true },
        { stepType: 'concatenate-by-chapter', enabled: true },
        { stepType: 'export-markdown', enabled: true }
    ]
};

Compile Engine Implementation

class CompileEngine {
    async compile(
        workflow: CompileWorkflow,
        draft: StoryDraft,
        options: CompileOptions = {}
    ): Promise<CompileResult> {
        // Get ordered scenes
        const orderedScenes = await this.sceneOrderManager.getOrderedScenes(draft);
        
        // Process each scene through workflow steps
        let processedContent: ProcessedScene[] = [];
        
        for (const scene of orderedScenes) {
            if (!scene.includeInCompile) continue;
            
            let content = await this.loadSceneContent(scene.scene);
            
            for (const step of workflow.steps) {
                if (!step.enabled) continue;
                content = await this.executeStep(step, content, scene);
            }
            
            processedContent.push({ scene, content });
        }
        
        // Final assembly
        const finalContent = this.assembleDocument(processedContent, workflow);
        
        // Export
        return this.export(finalContent, workflow);
    }
    
    private async executeStep(
        step: CompileStep,
        content: string,
        context: SceneContext
    ): Promise<string> {
        switch (step.stepType) {
            case 'strip-frontmatter':
                return content.replace(/^---[\s\S]*?---\n*/, '');
                
            case 'prepend-scene-title':
                const format = step.options.format || '## $1';
                const title = format.replace('$1', context.scene.name);
                return title + '\n\n' + content;
                
            case 'remove-wikilinks':
                const keepText = step.options.keepLinkText ?? true;
                if (keepText) {
                    return content.replace(/\[\[([^\]|]+)(\|[^\]]+)?\]\]/g, '$1');
                }
                return content.replace(/\[\[[^\]]+\]\]/g, '');
                
            case 'clean-content':
                return this.cleanContent(content, step.options);
                
            case 'extract-content-section':
                return this.extractSection(content, 'Content');
                
            // ... other step types
        }
    }
}

Scene Order Manager (SceneOrderManager.ts)

class SceneOrderManager {
    async getOrderedScenes(draft: StoryDraft): Promise<OrderedScene[]> {
        const scenes = await this.plugin.listScenes();
        const chapters = await this.plugin.listChapters();
        
        const ordered: OrderedScene[] = [];
        
        for (const ref of draft.sceneOrder) {
            const scene = scenes.find(s => s.id === ref.sceneId);
            if (!scene) continue;
            
            const chapter = chapters.find(c => c.id === scene.chapterId);
            
            ordered.push({
                scene,
                chapter,
                indent: ref.indent,
                includeInCompile: ref.includeInCompile
            });
        }
        
        return ordered;
    }
    
    async navigateToNextScene(): Promise<boolean> {
        // Navigation between scenes in draft order
    }
    
    async navigateToPreviousScene(): Promise<boolean> {
        // Navigation between scenes in draft order
    }
}

Word Count Tracker (WordCountTracker.ts)

class WordCountTracker {
    async calculateDraftWordCount(draft: StoryDraft): Promise<number> {
        let total = 0;
        const scenes = await this.plugin.listScenes();
        
        for (const ref of draft.sceneOrder) {
            if (!ref.includeInCompile) continue;
            
            const scene = scenes.find(s => s.id === ref.sceneId);
            if (!scene?.filePath) continue;
            
            const content = await this.vault.cachedRead(
                this.vault.getAbstractFileByPath(scene.filePath)
            );
            total += this.countWords(content);
        }
        
        return total;
    }
    
    async calculateChapterWordCounts(draft: StoryDraft): Promise<Map<string, number>> {
        // Word count per chapter
    }
    
    async getDraftStatistics(draft: StoryDraft): Promise<DraftStats> {
        return {
            totalScenes: draft.sceneOrder.length,
            includedScenes: draft.sceneOrder.filter(r => r.includeInCompile).length,
            excludedScenes: draft.sceneOrder.filter(r => !r.includeInCompile).length,
            totalWords: await this.calculateDraftWordCount(draft),
            chapterCount: /* unique chapters */,
            unassignedScenes: /* scenes without chapter */
        };
    }
}