| 1 | """ |
| 2 | Story discovery and ebook build pipeline. |
| 3 | |
| 4 | Scans the vault for metadata.md files (with YAML frontmatter), discovers |
| 5 | chapter files, merges them into a single markdown document, exports a DOCX |
| 6 | via pandoc, then converts that DOCX to EPUB and MOBI. |
| 7 | """ |
| 8 | |
| 9 | import logging |
| 10 | import os |
| 11 | import re |
| 12 | import subprocess |
| 13 | import tempfile |
| 14 | import unicodedata |
| 15 | from pathlib import Path |
| 16 | from typing import Optional |
| 17 | |
| 18 | import yaml |
| 19 | |
| 20 | logger = logging.getLogger(__name__) |
| 21 | |
| 22 | METADATA_FILENAME = "metadata.md" |
| 23 | DEFAULT_OUTPUT_FILE_MODE = 0o664 |
| 24 | EPUB_SUBDIR = "epub" |
| 25 | MOBI_SUBDIR = "mobi" |
| 26 | REFERENCE_DOC_PATH = Path("/app/reference.docx") |
| 27 | DEFAULT_OUTPUT_UID = 99 |
| 28 | DEFAULT_OUTPUT_GID = 100 |
| 29 | |
| 30 | |
| 31 | def parse_frontmatter(text: str) -> dict: |
| 32 | """Extract YAML frontmatter from a markdown file.""" |
| 33 | text = text.strip() |
| 34 | if not text.startswith("---"): |
| 35 | return {} |
| 36 | |
| 37 | end = text.find("---", 3) |
| 38 | if end == -1: |
| 39 | return {} |
| 40 | |
| 41 | yaml_block = text[3:end].strip() |
| 42 | try: |
| 43 | return yaml.safe_load(yaml_block) or {} |
| 44 | except yaml.YAMLError as e: |
| 45 | logger.error(f"Failed to parse YAML frontmatter: {e}") |
| 46 | return {} |
| 47 | |
| 48 | |
| 49 | def write_frontmatter(metadata: dict, original_text: str) -> str: |
| 50 | """Replace the YAML frontmatter in a markdown file, preserving body content.""" |
| 51 | body = "" |
| 52 | original_text = original_text.strip() |
| 53 | if original_text.startswith("---"): |
| 54 | end = original_text.find("---", 3) |
| 55 | if end != -1: |
| 56 | body = original_text[end + 3:].strip() |
| 57 | |
| 58 | yaml_str = yaml.dump(metadata, default_flow_style=False, allow_unicode=True).strip() |
| 59 | result = f"---\n{yaml_str}\n---" |
| 60 | if body: |
| 61 | result += f"\n\n{body}" |
| 62 | return result + "\n" |
| 63 | |
| 64 | |
| 65 | def natural_sort_key(path: Path) -> tuple: |
| 66 | """Sort chapter files by chapter number when possible.""" |
| 67 | name = path.stem |
| 68 | |
| 69 | match = re.search(r"[Cc]hapter\s+(\d+)", name) |
| 70 | if match: |
| 71 | return (0, int(match.group(1)), name) |
| 72 | |
| 73 | match = re.match(r"(\d+)", name) |
| 74 | if match: |
| 75 | return (0, int(match.group(1)), name) |
| 76 | |
| 77 | return (1, 0, name) |
| 78 | |
| 79 | |
| 80 | def discover_stories(vault_path: Path, creative_folder: str = "Creative") -> list[dict]: |
| 81 | """Recursively scan for metadata.md files under the creative folder.""" |
| 82 | creative_path = vault_path / creative_folder |
| 83 | if not creative_path.exists(): |
| 84 | logger.warning(f"Creative folder not found: {creative_path}") |
| 85 | return [] |
| 86 | |
| 87 | stories = [] |
| 88 | for metadata_file in creative_path.rglob(METADATA_FILENAME): |
| 89 | story_dir = metadata_file.parent |
| 90 | try: |
| 91 | raw_text = metadata_file.read_text(encoding="utf-8") |
| 92 | metadata = parse_frontmatter(raw_text) |
| 93 | except Exception as e: |
| 94 | logger.error(f"Failed to parse {metadata_file}: {e}") |
| 95 | continue |
| 96 | |
| 97 | if not metadata: |
| 98 | logger.warning(f"No valid frontmatter in {metadata_file}, skipping") |
| 99 | continue |
| 100 | |
| 101 | chapters = sorted( |
| 102 | [f for f in story_dir.iterdir() if f.suffix == ".md" and f.name != METADATA_FILENAME], |
| 103 | key=natural_sort_key, |
| 104 | ) |
| 105 | |
| 106 | stories.append( |
| 107 | { |
| 108 | "path": story_dir, |
| 109 | "metadata_file": metadata_file, |
| 110 | "metadata": metadata, |
| 111 | "metadata_raw": raw_text, |
| 112 | "chapters": chapters, |
| 113 | } |
| 114 | ) |
| 115 | logger.info( |
| 116 | f"Discovered story: {metadata.get('title', story_dir.name)} " |
| 117 | f"({len(chapters)} chapters)" |
| 118 | ) |
| 119 | |
| 120 | return stories |
| 121 | |
| 122 | |
| 123 | def merge_chapters(chapters: list[Path], add_page_breaks: bool = True) -> str: |
| 124 | """Merge chapter markdown files into a single document.""" |
| 125 | merged_parts = [] |
| 126 | |
| 127 | for i, chapter_file in enumerate(chapters): |
| 128 | try: |
| 129 | content = chapter_file.read_text(encoding="utf-8").strip() |
| 130 | except Exception as e: |
| 131 | logger.error(f"Failed to read {chapter_file}: {e}") |
| 132 | continue |
| 133 | |
| 134 | if i > 0 and add_page_breaks: |
| 135 | # Use Pandoc's explicit page break marker so DOCX gets a real break. |
| 136 | merged_parts.append("\n\n\\newpage\n\n") |
| 137 | |
| 138 | merged_parts.append(content) |
| 139 | |
| 140 | return "\n\n".join(merged_parts) |
| 141 | |
| 142 | |
| 143 | def normalize_text_for_docx(text: str) -> str: |
| 144 | """Normalize markdown text before DOCX export. |
| 145 | |
| 146 | NFC is a conservative choice that can reduce weirdness around combining |
| 147 | characters without stripping the visual effect from decorated text. |
| 148 | """ |
| 149 | return unicodedata.normalize("NFC", text) |
| 150 | |
| 151 | |
| 152 | def sanitize_title(title: str) -> str: |
| 153 | """Convert a story title into a safe base filename.""" |
| 154 | return re.sub(r"[^\w\s-]", "", title).strip() |
| 155 | |
| 156 | |
| 157 | def get_cover_path(story: dict) -> Optional[Path]: |
| 158 | """Resolve a configured or auto-detected cover image for the story.""" |
| 159 | metadata = story["metadata"] |
| 160 | cover = metadata.get("cover") |
| 161 | |
| 162 | if cover: |
| 163 | cover_path = story["path"] / cover |
| 164 | if cover_path.exists(): |
| 165 | return cover_path |
| 166 | logger.warning(f"Cover image specified but not found: {cover_path}") |
| 167 | return None |
| 168 | |
| 169 | for ext in ["jpg", "jpeg", "png"]: |
| 170 | candidate = story["path"] / f"cover.{ext}" |
| 171 | if candidate.exists(): |
| 172 | return candidate |
| 173 | |
| 174 | return None |
| 175 | |
| 176 | |
| 177 | def set_output_permissions(*paths: Path): |
| 178 | """Set predictable readable permissions on generated output files.""" |
| 179 | for path in paths: |
| 180 | os.chmod(path, DEFAULT_OUTPUT_FILE_MODE) |
| 181 | |
| 182 | |
| 183 | def set_output_ownership(*paths: Path): |
| 184 | """Set predictable ownership on generated output paths.""" |
| 185 | for path in paths: |
| 186 | os.chown(path, DEFAULT_OUTPUT_UID, DEFAULT_OUTPUT_GID) |
| 187 | |
| 188 | |
| 189 | def run_command(cmd: list[str], title: str, output_label: str) -> bool: |
| 190 | """Run an external command and log any failure details.""" |
| 191 | logger.debug(f"Running: {' '.join(cmd)}") |
| 192 | |
| 193 | result = subprocess.run( |
| 194 | cmd, |
| 195 | capture_output=True, |
| 196 | text=True, |
| 197 | timeout=240, |
| 198 | ) |
| 199 | |
| 200 | if result.returncode != 0: |
| 201 | logger.error(f"{output_label} build failed for '{title}':\n{result.stderr}") |
| 202 | return False |
| 203 | |
| 204 | if result.stderr: |
| 205 | logger.warning(f"{output_label} warnings for '{title}':\n{result.stderr}") |
| 206 | |
| 207 | return True |
| 208 | |
| 209 | |
| 210 | def build_outputs( |
| 211 | story: dict, |
| 212 | output_dir: Path, |
| 213 | lua_filter: Optional[Path] = None, |
| 214 | pagebreak_filter: Optional[Path] = None, |
| 215 | css_file: Optional[Path] = None, |
| 216 | ) -> Optional[dict[str, Path]]: |
| 217 | """Build DOCX, EPUB, and MOBI outputs for a story.""" |
| 218 | metadata = story["metadata"] |
| 219 | title = metadata.get("title", story["path"].name) |
| 220 | author = metadata.get("author", "Unknown Author") |
| 221 | language = metadata.get("language", "en") |
| 222 | |
| 223 | safe_title = sanitize_title(title) |
| 224 | epub_dir = output_dir / EPUB_SUBDIR |
| 225 | mobi_dir = output_dir / MOBI_SUBDIR |
| 226 | docx_file = output_dir / f"{safe_title}.docx" |
| 227 | epub_file = epub_dir / f"{safe_title}.epub" |
| 228 | mobi_file = mobi_dir / f"{safe_title}.mobi" |
| 229 | output_dir.mkdir(parents=True, exist_ok=True) |
| 230 | epub_dir.mkdir(parents=True, exist_ok=True) |
| 231 | mobi_dir.mkdir(parents=True, exist_ok=True) |
| 232 | set_output_ownership(output_dir, epub_dir, mobi_dir) |
| 233 | |
| 234 | if not story["chapters"]: |
| 235 | logger.warning(f"No chapters found for '{title}', skipping") |
| 236 | return None |
| 237 | |
| 238 | logger.info(f"Building book files: '{title}' ({len(story['chapters'])} chapters)") |
| 239 | |
| 240 | merged_content = normalize_text_for_docx(merge_chapters(story["chapters"])) |
| 241 | cover_path = get_cover_path(story) |
| 242 | |
| 243 | with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False, encoding="utf-8") as tmp: |
| 244 | tmp.write(merged_content) |
| 245 | tmp_path = tmp.name |
| 246 | |
| 247 | try: |
| 248 | docx_cmd = [ |
| 249 | "pandoc", |
| 250 | tmp_path, |
| 251 | "-o", |
| 252 | str(docx_file), |
| 253 | "--from", |
| 254 | "markdown+raw_html+raw_tex", |
| 255 | "--to", |
| 256 | "docx", |
| 257 | "--metadata", |
| 258 | f"title={title}", |
| 259 | "--metadata", |
| 260 | f"author={author}", |
| 261 | "--metadata", |
| 262 | f"lang={language}", |
| 263 | "--wrap=none", |
| 264 | ] |
| 265 | |
| 266 | if REFERENCE_DOC_PATH.exists(): |
| 267 | docx_cmd.extend(["--reference-doc", str(REFERENCE_DOC_PATH)]) |
| 268 | |
| 269 | if lua_filter and lua_filter.exists(): |
| 270 | docx_cmd.extend(["--lua-filter", str(lua_filter)]) |
| 271 | if pagebreak_filter and pagebreak_filter.exists(): |
| 272 | docx_cmd.extend(["--lua-filter", str(pagebreak_filter)]) |
| 273 | |
| 274 | if not run_command(docx_cmd, title, "DOCX"): |
| 275 | return None |
| 276 | logger.info(f"DOCX created: {docx_file}") |
| 277 | |
| 278 | base_convert_cmd = [ |
| 279 | "--title", |
| 280 | title, |
| 281 | "--authors", |
| 282 | author, |
| 283 | "--language", |
| 284 | language, |
| 285 | "--chapter", |
| 286 | "//h:h1|//h:h2", |
| 287 | "--level1-toc", |
| 288 | "//h:h1", |
| 289 | "--level2-toc", |
| 290 | "//h:h2", |
| 291 | "--max-toc-links", |
| 292 | "500", |
| 293 | ] |
| 294 | |
| 295 | epub_cmd = ["ebook-convert", str(docx_file), str(epub_file), *base_convert_cmd] |
| 296 | mobi_cmd = ["ebook-convert", str(docx_file), str(mobi_file), *base_convert_cmd] |
| 297 | |
| 298 | if cover_path: |
| 299 | logger.info(f"Using cover image: {cover_path.name}") |
| 300 | epub_cmd.extend(["--cover", str(cover_path)]) |
| 301 | mobi_cmd.extend(["--cover", str(cover_path)]) |
| 302 | |
| 303 | if css_file and css_file.exists(): |
| 304 | epub_cmd.extend(["--extra-css", str(css_file)]) |
| 305 | mobi_cmd.extend(["--extra-css", str(css_file)]) |
| 306 | |
| 307 | if not run_command(epub_cmd, title, "EPUB"): |
| 308 | return None |
| 309 | logger.info(f"EPUB created: {epub_file}") |
| 310 | |
| 311 | if not run_command(mobi_cmd, title, "MOBI"): |
| 312 | return None |
| 313 | logger.info(f"MOBI created: {mobi_file}") |
| 314 | |
| 315 | set_output_permissions(docx_file, epub_file, mobi_file) |
| 316 | set_output_ownership(docx_file, epub_file, mobi_file) |
| 317 | return {"docx": docx_file, "epub": epub_file, "mobi": mobi_file} |
| 318 | |
| 319 | except subprocess.TimeoutExpired: |
| 320 | logger.error(f"Build timed out for '{title}'") |
| 321 | except Exception as e: |
| 322 | logger.error(f"Build failed for '{title}': {e}") |
| 323 | finally: |
| 324 | Path(tmp_path).unlink(missing_ok=True) |
| 325 | |
| 326 | return None |