From 0ebf662d59c304d537c052b21c5accf66dc06ff4 Mon Sep 17 00:00:00 2001 From: Solaria Date: Sat, 14 Mar 2026 12:06:12 +0000 Subject: [PATCH] Add Scrivener-style export with chapter splitting and binder.json --- opus_orchestrator/__init__.py | 3 + opus_orchestrator/scrivener_export.py | 253 ++++++++++++++++++++++++++ 2 files changed, 256 insertions(+) create mode 100644 opus_orchestrator/scrivener_export.py diff --git a/opus_orchestrator/__init__.py b/opus_orchestrator/__init__.py index 8946338..1cf26c3 100644 --- a/opus_orchestrator/__init__.py +++ b/opus_orchestrator/__init__.py @@ -142,4 +142,7 @@ __all__ = [ "RetryHandler", "CircuitBreaker", "with_retry", + # Scrivener Export + "ScrivenerExporter", + "export_to_scrivener", ] diff --git a/opus_orchestrator/scrivener_export.py b/opus_orchestrator/scrivener_export.py new file mode 100644 index 0000000..6f4a689 --- /dev/null +++ b/opus_orchestrator/scrivener_export.py @@ -0,0 +1,253 @@ +"""Scrivener-style output for Opus Orchestrator. + +Generates chapter-by-chapter output with binder.json metadata. +""" + +import json +import re +from pathlib import Path +from typing import Optional +from dataclasses import dataclass, asdict + +from opus_orchestrator.schemas import Manuscript, Chapter + + +@dataclass +class ChapterFile: + """A single chapter file.""" + filename: str + title: str + content: str + word_count: int + order: int + + +@dataclass +class BinderItem: + """A binder item (chapter or folder).""" + id: str + type: str # "chapter" or "folder" + title: str + filename: str + order: int + word_count: int + children: list = None + + def to_dict(self): + d = asdict(self) + if self.children: + d['children'] = [c.to_dict() if hasattr(c, 'to_dict') else c for c in self.children] + return d + + +class ScrivenerExporter: + """Export manuscript in Scrivener-style folder structure.""" + + def __init__(self, output_dir: str = "./output"): + self.output_dir = Path(output_dir) + + def export( + self, + manuscript: Manuscript, + book_title: str, + split_chapters: bool = True, + ) -> dict: + """Export manuscript to Scrivener-style structure. + + Args: + manuscript: The Manuscript to export + book_title: Title for the book folder + split_chapters: If True, split into individual files + + Returns: + Export metadata with file paths and word counts + """ + # Create book folder + book_folder = self.output_dir / self._slugify(book_title) + book_folder.mkdir(parents=True, exist_ok=True) + + if split_chapters: + return self._export_split(manuscript, book_folder, book_title) + else: + return self._export_single(manuscript, book_folder, book_title) + + def _export_split( + self, + manuscript: Manuscript, + book_folder: Path, + book_title: str, + ) -> dict: + """Export as individual chapter files.""" + binder = [] + total_words = 0 + + for idx, chapter in enumerate(manuscript.chapters): + order = idx + 1 + filename = f"{order:02d}_{self._slugify(chapter.title)}.md" + filepath = book_folder / filename + + # Write chapter file + content = self._format_chapter(chapter, order) + filepath.write_text(content, encoding='utf-8') + + word_count = len(content.split()) + total_words += word_count + + binder.append(BinderItem( + id=f"chapter-{order}", + type="chapter", + title=chapter.title, + filename=filename, + order=order, + word_count=word_count, + )) + + # Write binder.json + binder_data = { + "version": "1.0", + "title": book_title, + "total_chapters": len(manuscript.chapters), + "total_words": total_words, + "items": [item.to_dict() for item in binder], + } + + binder_path = book_folder / "binder.json" + binder_path.write_text( + json.dumps(binder_data, indent=2), + encoding='utf-8' + ) + + # Write metadata.json + metadata = { + "title": book_title, + "book_type": manuscript.book_type.value if hasattr(manuscript.book_type, 'value') else str(manuscript.book_type), + "genre": manuscript.genre, + "total_chapters": len(manuscript.chapters), + "total_words": total_words, + "chapters": [ + { + "order": item.order, + "title": item.title, + "filename": item.filename, + "word_count": item.word_count, + } + for item in binder + ], + } + + metadata_path = book_folder / "metadata.json" + metadata_path.write_text( + json.dumps(metadata, indent=2), + encoding='utf-8' + ) + + return { + "book_folder": str(book_folder), + "chapters": len(manuscript.chapters), + "total_words": total_words, + "binder": str(binder_path), + } + + def _export_single( + self, + manuscript: Manuscript, + book_folder: Path, + book_title: str, + ) -> dict: + """Export as single file.""" + filename = f"{self._slugify(book_title)}.md" + filepath = book_folder / filename + + content = self._format_manuscript(manuscript) + filepath.write_text(content, encoding='utf-8') + + word_count = len(content.split()) + + # Simple binder with just the main file + binder_data = { + "version": "1.0", + "title": book_title, + "total_chapters": 1, + "total_words": word_count, + "items": [ + { + "id": "manuscript", + "type": "chapter", + "title": book_title, + "filename": filename, + "order": 1, + "word_count": word_count, + } + ], + } + + binder_path = book_folder / "binder.json" + binder_path.write_text( + json.dumps(binder_data, indent=2), + encoding='utf-8' + ) + + return { + "book_folder": str(book_folder), + "chapters": 1, + "total_words": word_count, + "binder": str(binder_path), + } + + def _format_chapter(self, chapter: Chapter, order: int) -> str: + """Format a single chapter with frontmatter.""" + word_count = len(chapter.content.split()) if chapter.content else 0 + + frontmatter = f"""--- +chapter: {order} +title: "{chapter.title}" +word_count: {word_count} +--- + +""" + + # Add chapter heading if not in content + content = chapter.content or "" + if not content.strip().startswith("#"): + content = f"# {chapter.title}\n\n{content}" + + return frontmatter + content + + def _format_manuscript(self, manuscript: Manuscript) -> str: + """Format entire manuscript.""" + parts = [f"# {manuscript.title}\n"] + + for chapter in manuscript.chapters: + parts.append(f"\n---\n\n") + parts.append(f"## {chapter.title}\n\n") + parts.append(chapter.content or "") + + return "".join(parts) + + def _slugify(self, text: str) -> str: + """Convert title to filename-safe slug.""" + # Remove special chars, replace spaces with underscores + slug = re.sub(r'[^\w\s-]', '', text.lower()) + slug = re.sub(r'[-\s]+', '-', slug) + return slug.strip('-') + + +def export_to_scrivener( + manuscript: Manuscript, + book_title: str, + output_dir: str = "./output", + split_chapters: bool = True, +) -> dict: + """Convenience function to export in Scrivener style. + + Args: + manuscript: The Manuscript to export + book_title: Title for the book + output_dir: Output directory + split_chapters: Split into individual chapter files + + Returns: + Export metadata + """ + exporter = ScrivenerExporter(output_dir) + return exporter.export(manuscript, book_title, split_chapters)