Blame Raw
permissionBRICK · 9f53616a · · 326 lines (9.8 KB)
1 contributor
1"""
2Story discovery and ebook build pipeline.
3
4Scans the vault for metadata.md files (with YAML frontmatter), discovers
5chapter files, merges them into a single markdown document, exports a DOCX
6via pandoc, then converts that DOCX to EPUB and MOBI.
7"""
8
9import logging
10import os
11import re
12import subprocess
13import tempfile
14import unicodedata
15from pathlib import Path
16from typing import Optional
17
18import yaml
19
20logger = logging.getLogger(__name__)
21
22METADATA_FILENAME = "metadata.md"
23DEFAULT_OUTPUT_FILE_MODE = 0o664
24EPUB_SUBDIR = "epub"
25MOBI_SUBDIR = "mobi"
26REFERENCE_DOC_PATH = Path("/app/reference.docx")
27DEFAULT_OUTPUT_UID = 99
28DEFAULT_OUTPUT_GID = 100
29
30
31def 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
49def 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
65def 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
80def 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
123def 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
143def 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
152def 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
157def 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
177def 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
183def 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
189def 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
210def 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