Add ExportOptions and auto-branch push to Scrivener export
This commit is contained in:
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user