Storyteller Suite - Import/Export System
Overview
The plugin supports:
- Import: Parse external documents into chapters/scenes
- Export: Compile manuscripts from scenes with customizable workflows
- Entity Extraction: Auto-detect characters/locations from imported text
Import System
Supported Formats
| Format | Extension | Parser File |
|---|---|---|
| Microsoft Word | .docx |
DocxParser.ts |
| EPUB | .epub |
EpubParser.ts |
.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 */
};
}
}