Add Scrivener-style export with chapter splitting and binder.json
This commit is contained in:
@@ -142,4 +142,7 @@ __all__ = [
|
||||
"RetryHandler",
|
||||
"CircuitBreaker",
|
||||
"with_retry",
|
||||
# Scrivener Export
|
||||
"ScrivenerExporter",
|
||||
"export_to_scrivener",
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user