Add Scrivener-style export with chapter splitting and binder.json

This commit is contained in:
Solaria
2026-03-14 12:06:12 +00:00
parent 134b74d690
commit 0ebf662d59
2 changed files with 256 additions and 0 deletions
+3
View File
@@ -142,4 +142,7 @@ __all__ = [
"RetryHandler",
"CircuitBreaker",
"with_retry",
# Scrivener Export
"ScrivenerExporter",
"export_to_scrivener",
]
+253
View File
@@ -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)