Add ExportOptions and auto-branch push to Scrivener export

This commit is contained in:
Solaria
2026-03-14 12:52:32 +00:00
parent 0ebf662d59
commit c42556d147
2 changed files with 161 additions and 3 deletions
+13
View File
@@ -103,6 +103,17 @@ def __getattr__(name: str):
from opus_orchestrator.utils.retry import with_retry from opus_orchestrator.utils.retry import with_retry
return with_retry return with_retry
# Scrivener Export
if name == "ScrivenerExporter":
from opus_orchestrator.scrivener_export import ScrivenerExporter
return ScrivenerExporter
if name == "export_to_scrivener":
from opus_orchestrator.scrivener_export import export_to_scrivener
return export_to_scrivener
if name == "ExportOptions":
from opus_orchestrator.scrivener_export import ExportOptions
return ExportOptions
raise AttributeError(f"module {__name__!r} has no attribute {name!r}") raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -145,4 +156,6 @@ __all__ = [
# Scrivener Export # Scrivener Export
"ScrivenerExporter", "ScrivenerExporter",
"export_to_scrivener", "export_to_scrivener",
"ExportOptions",
"ExportOptions",
] ]
+148 -3
View File
@@ -1,17 +1,36 @@
"""Scrivener-style output for Opus Orchestrator. """Scrivener-style output for Opus Orchestrator.
Generates chapter-by-chapter output with binder.json metadata. Generates chapter-by-chapter output with binder.json metadata.
Supports auto-branching and push to GitHub.
""" """
import json import json
import re import re
import subprocess
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
from dataclasses import dataclass, asdict from dataclasses import dataclass, asdict
from datetime import datetime
from opus_orchestrator.schemas import Manuscript, Chapter 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 @dataclass
class ChapterFile: class ChapterFile:
"""A single chapter file.""" """A single chapter file."""
@@ -50,22 +69,47 @@ class ScrivenerExporter:
self, self,
manuscript: Manuscript, manuscript: Manuscript,
book_title: str, book_title: str,
split_chapters: bool = True, options: Optional[ExportOptions] = None,
) -> dict: ) -> dict:
"""Export manuscript to Scrivener-style structure. """Export manuscript to Scrivener-style structure.
Args: Args:
manuscript: The Manuscript to export manuscript: The Manuscript to export
book_title: Title for the book folder book_title: Title for the book folder
split_chapters: If True, split into individual files options: ExportOptions (optional)
Returns: Returns:
Export metadata with file paths and word counts 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 # Create book folder
book_folder = self.output_dir / self._slugify(book_title) book_folder = self.output_dir / self._slugify(book_title)
book_folder.mkdir(parents=True, exist_ok=True) 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: if split_chapters:
return self._export_split(manuscript, book_folder, book_title) return self._export_split(manuscript, book_folder, book_title)
else: else:
@@ -232,11 +276,101 @@ word_count: {word_count}
return slug.strip('-') 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( def export_to_scrivener(
manuscript: Manuscript, manuscript: Manuscript,
book_title: str, book_title: str,
output_dir: str = "./output", output_dir: str = "./output",
split_chapters: bool = True, split_chapters: bool = True,
branch: str = "draft/generated",
push_to_remote: bool = False,
commit_message: str = "",
) -> dict: ) -> dict:
"""Convenience function to export in Scrivener style. """Convenience function to export in Scrivener style.
@@ -245,9 +379,20 @@ def export_to_scrivener(
book_title: Title for the book book_title: Title for the book
output_dir: Output directory output_dir: Output directory
split_chapters: Split into individual chapter files 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: Returns:
Export metadata 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) exporter = ScrivenerExporter(output_dir)
return exporter.export(manuscript, book_title, split_chapters) return exporter.export(manuscript, book_title, options)