254 lines
7.4 KiB
Python
254 lines
7.4 KiB
Python
"""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)
|