399 lines
12 KiB
Python
399 lines
12 KiB
Python
"""Scrivener-style output for Opus Orchestrator.
|
|
|
|
Generates chapter-by-chapter output with binder.json metadata.
|
|
Supports auto-branching and push to GitHub.
|
|
"""
|
|
|
|
import json
|
|
import re
|
|
import subprocess
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
from dataclasses import dataclass, asdict
|
|
from datetime import datetime
|
|
|
|
from opus_orchestrator.schemas import Manuscript, Chapter
|
|
|
|
|
|
@dataclass
|
|
class ExportOptions:
|
|
"""Options for Scrivener export."""
|
|
output_dir: str = "./output"
|
|
split_chapters: bool = True
|
|
branch: str = "draft/generated"
|
|
push_to_remote: bool = False
|
|
commit_message: str = ""
|
|
author_name: str = "Opus Orchestrator"
|
|
author_email: str = "opus@clowder.net"
|
|
|
|
def __post_init__(self):
|
|
if not self.commit_message:
|
|
self.commit_message = f"Auto-export: {datetime.now().strftime('%Y-%m-%d %H:%M')}"
|
|
|
|
|
|
@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,
|
|
options: Optional[ExportOptions] = None,
|
|
) -> dict:
|
|
"""Export manuscript to Scrivener-style structure.
|
|
|
|
Args:
|
|
manuscript: The Manuscript to export
|
|
book_title: Title for the book folder
|
|
options: ExportOptions (optional)
|
|
|
|
Returns:
|
|
Export metadata with file paths and word counts
|
|
"""
|
|
opts = options or ExportOptions(
|
|
output_dir=opts.output_dir if options else "./output",
|
|
split_chapters=options.split_chapters if options else True,
|
|
)
|
|
|
|
# Create book folder
|
|
book_folder = self.output_dir / self._slugify(book_title)
|
|
book_folder.mkdir(parents=True, exist_ok=True)
|
|
|
|
if opts.split_chapters:
|
|
result = self._export_split(manuscript, book_folder, book_title)
|
|
else:
|
|
result = self._export_single(manuscript, book_folder, book_title)
|
|
|
|
# Optionally push to GitHub
|
|
if opts.push_to_remote:
|
|
push_result = self._push_to_git(
|
|
book_folder,
|
|
opts.branch,
|
|
opts.commit_message,
|
|
opts.author_name,
|
|
opts.author_email,
|
|
)
|
|
result.update(push_result)
|
|
|
|
return result
|
|
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 _push_to_git(
|
|
self,
|
|
folder: Path,
|
|
branch: str,
|
|
commit_message: str,
|
|
author_name: str,
|
|
author_email: str,
|
|
) -> dict:
|
|
"""Push exported folder to GitHub.
|
|
|
|
Args:
|
|
folder: Folder to push
|
|
branch: Branch name (will be created if doesn't exist)
|
|
commit_message: Commit message
|
|
author_name: Git author name
|
|
author_email: Git author email
|
|
|
|
Returns:
|
|
Push result metadata
|
|
"""
|
|
import subprocess
|
|
|
|
# Check if git repo exists
|
|
git_dir = folder / ".git"
|
|
if not git_dir.exists():
|
|
# Initialize new repo
|
|
subprocess.run(["git", "init"], cwd=folder, check=True, capture_output=True)
|
|
subprocess.run(["git", "config", "user.email", author_email], cwd=folder, check=True, capture_output=True)
|
|
subprocess.run(["git", "config", "user.name", author_name], cwd=folder, check=True, capture_output=True)
|
|
|
|
# Add all files
|
|
subprocess.run(["git", "add", "."], cwd=folder, check=True, capture_output=True)
|
|
|
|
# Check if there are changes
|
|
result = subprocess.run(
|
|
["git", "status", "--porcelain"],
|
|
cwd=folder,
|
|
capture_output=True,
|
|
text=True
|
|
)
|
|
|
|
if not result.stdout.strip():
|
|
return {"pushed": False, "reason": "No changes to commit"}
|
|
|
|
# Commit
|
|
subprocess.run(
|
|
["git", "commit", "-m", commit_message],
|
|
cwd=folder,
|
|
check=True,
|
|
capture_output=True
|
|
)
|
|
|
|
# Get or add remote
|
|
remotes = subprocess.run(
|
|
["git", "remote", "-v"],
|
|
cwd=folder,
|
|
capture_output=True,
|
|
text=True
|
|
)
|
|
|
|
if not remotes.stdout.strip():
|
|
# No remote set - can't push
|
|
return {
|
|
"pushed": False,
|
|
"reason": "No remote configured. Add one with: git remote add origin <url>"
|
|
}
|
|
|
|
# Push to branch
|
|
try:
|
|
subprocess.run(
|
|
["git", "push", "-u", "origin", branch, "--no-verify"],
|
|
cwd=folder,
|
|
check=True,
|
|
capture_output=True
|
|
)
|
|
return {
|
|
"pushed": True,
|
|
"branch": branch,
|
|
"commit_message": commit_message,
|
|
}
|
|
except subprocess.CalledProcessError as e:
|
|
return {
|
|
"pushed": False,
|
|
"reason": f"Push failed: {e.stderr.decode() if e.stderr else str(e)}"
|
|
}
|
|
|
|
|
|
def export_to_scrivener(
|
|
manuscript: Manuscript,
|
|
book_title: str,
|
|
output_dir: str = "./output",
|
|
split_chapters: bool = True,
|
|
branch: str = "draft/generated",
|
|
push_to_remote: bool = False,
|
|
commit_message: str = "",
|
|
) -> 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
|
|
branch: Branch to push to (default: draft/generated)
|
|
push_to_remote: Whether to push to GitHub remote
|
|
commit_message: Custom commit message
|
|
|
|
Returns:
|
|
Export metadata
|
|
"""
|
|
options = ExportOptions(
|
|
output_dir=output_dir,
|
|
split_chapters=split_chapters,
|
|
branch=branch,
|
|
push_to_remote=push_to_remote,
|
|
commit_message=commit_message,
|
|
)
|
|
|
|
exporter = ScrivenerExporter(output_dir)
|
|
return exporter.export(manuscript, book_title, options)
|