v2 - docx and convert
| @@ -1,7 +1,7 @@ | |||
| 1 | FROM python:3.12-slim | 1 | FROM python:3.12-slim |
| 2 | 2 | ||
| 3 | RUN apt-get update && \ | 3 | RUN apt-get update && \ |
| 4 | apt-get install -y --no-install-recommends pandoc && \ | 4 | apt-get install -y --no-install-recommends pandoc calibre && \ |
| 5 | rm -rf /var/lib/apt/lists/* | 5 | rm -rf /var/lib/apt/lists/* |
| 6 | 6 | ||
| 7 | RUN mkdir -p /app /output | 7 | RUN mkdir -p /app /output |
| @@ -1,14 +1,15 @@ | |||
| 1 | # Obsidian Markdown to EPUB Pipeline | 1 | # Obsidian Markdown to Ebook Pipeline |
| 2 | 2 | ||
| 3 | This container watches a mounted markdown share for story folders and generates EPUB files into a separate mounted output folder. | 3 | This container watches a mounted markdown share for story folders and generates DOCX, EPUB, and MOBI files into a separate mounted output folder. |
| 4 | 4 | ||
| 5 | ## How it works | 5 | ## How it works |
| 6 | 6 | ||
| 7 | 1. The container scans the mounted vault under `/vault/<CREATIVE_FOLDER>`. | 7 | 1. The container scans the mounted vault under `/vault/<CREATIVE_FOLDER>`. |
| 8 | 2. Each story folder is identified by a `metadata.md` file with YAML frontmatter. | 8 | 2. Each story folder is identified by a `metadata.md` file with YAML frontmatter. |
| 9 | 3. If that frontmatter contains `export: true`, the container merges the sibling chapter `.md` files in that folder and converts them to EPUB with `pandoc`. | 9 | 3. If that frontmatter contains `export: true`, the container merges the sibling chapter `.md` files in that folder and exports a DOCX with `pandoc`. |
| 10 | 4. The finished EPUB is written to `/output` and explicitly set to mode `0644` so it is readable by all users. | 10 | 4. That DOCX is then converted into EPUB and MOBI with Calibre's `ebook-convert`. |
| 11 | 5. The container resets `export: false` in the story's `metadata.md`. | 11 | 5. The finished files are written to `/output`, with `.docx` in the root plus `/output/epub` and `/output/mobi` subfolders for the other formats. All generated files are set to mode `0644` so they are readable by all users. |
| 12 | 6. The container resets `export: false` in the story's `metadata.md`. | ||
| 12 | 13 | ||
| 13 | ## Story layout | 14 | ## Story layout |
| 14 | 15 | ||
| @@ -59,7 +60,7 @@ docker compose logs -f | |||
| 59 | | `VAULT_PATH` | `/vault` | Mounted source folder | | 60 | | `VAULT_PATH` | `/vault` | Mounted source folder | |
| 60 | | `OUTPUT_DIR` | `/output` | Mounted output folder | | 61 | | `OUTPUT_DIR` | `/output` | Mounted output folder | |
| 61 | | `LUA_FILTER_PATH` | `/app/center-v.lua` | Pandoc Lua filter | | 62 | | `LUA_FILTER_PATH` | `/app/center-v.lua` | Pandoc Lua filter | |
| 62 | | `CSS_FILE_PATH` | `/app/epub-style.css` | EPUB stylesheet | | 63 | | `CSS_FILE_PATH` | `/app/epub-style.css` | Extra stylesheet used during EPUB and MOBI conversion | |
| 63 | 64 | ||
| 64 | ## Docker Hub publishing flow | 65 | ## Docker Hub publishing flow |
| 65 | 66 | ||
| @@ -1,9 +1,9 @@ | |||
| 1 | """ | 1 | """ |
| 2 | Story discovery and EPUB build pipeline. | 2 | Story discovery and ebook build pipeline. |
| 3 | 3 | ||
| 4 | Scans the vault for metadata.md files (with YAML frontmatter), discovers | 4 | Scans the vault for metadata.md files (with YAML frontmatter), discovers |
| 5 | chapter files, merges them into a single markdown document, and converts | 5 | chapter files, merges them into a single markdown document, exports a DOCX |
| 6 | to EPUB via pandoc. | 6 | via pandoc, then converts that DOCX to EPUB and MOBI. |
| 7 | """ | 7 | """ |
| 8 | 8 | ||
| 9 | import logging | 9 | import logging |
| @@ -20,21 +20,20 @@ logger = logging.getLogger(__name__) | |||
| 20 | 20 | ||
| 21 | METADATA_FILENAME = "metadata.md" | 21 | METADATA_FILENAME = "metadata.md" |
| 22 | DEFAULT_OUTPUT_FILE_MODE = 0o644 | 22 | DEFAULT_OUTPUT_FILE_MODE = 0o644 |
| 23 | EPUB_SUBDIR = "epub" | ||
| 24 | MOBI_SUBDIR = "mobi" | ||
| 23 | 25 | ||
| 24 | 26 | ||
| 25 | def parse_frontmatter(text: str) -> dict: | 27 | def parse_frontmatter(text: str) -> dict: |
| 26 | """Extract YAML frontmatter from a markdown file. | 28 | """Extract YAML frontmatter from a markdown file.""" |
| 27 | |||
| 28 | Expects the file to start with '---', followed by YAML, closed by '---'. | ||
| 29 | Returns the parsed dict, or {} if no valid frontmatter found. | ||
| 30 | """ | ||
| 31 | text = text.strip() | 29 | text = text.strip() |
| 32 | if not text.startswith("---"): | 30 | if not text.startswith("---"): |
| 33 | return {} | 31 | return {} |
| 34 | # Find the closing --- | 32 | |
| 35 | end = text.find("---", 3) | 33 | end = text.find("---", 3) |
| 36 | if end == -1: | 34 | if end == -1: |
| 37 | return {} | 35 | return {} |
| 36 | |||
| 38 | yaml_block = text[3:end].strip() | 37 | yaml_block = text[3:end].strip() |
| 39 | try: | 38 | try: |
| 40 | return yaml.safe_load(yaml_block) or {} | 39 | return yaml.safe_load(yaml_block) or {} |
| @@ -44,10 +43,7 @@ def parse_frontmatter(text: str) -> dict: | |||
| 44 | 43 | ||
| 45 | 44 | ||
| 46 | def write_frontmatter(metadata: dict, original_text: str) -> str: | 45 | def write_frontmatter(metadata: dict, original_text: str) -> str: |
| 47 | """Replace the YAML frontmatter in a markdown file, preserving body content. | 46 | """Replace the YAML frontmatter in a markdown file, preserving body content.""" |
| 48 | |||
| 49 | If there's content after the frontmatter, it's kept intact. | ||
| 50 | """ | ||
| 51 | body = "" | 47 | body = "" |
| 52 | original_text = original_text.strip() | 48 | original_text = original_text.strip() |
| 53 | if original_text.startswith("---"): | 49 | if original_text.startswith("---"): |
| @@ -63,38 +59,22 @@ def write_frontmatter(metadata: dict, original_text: str) -> str: | |||
| 63 | 59 | ||
| 64 | 60 | ||
| 65 | def natural_sort_key(path: Path) -> tuple: | 61 | def natural_sort_key(path: Path) -> tuple: |
| 66 | """Sort chapter files by their chapter number naturally. | 62 | """Sort chapter files by chapter number when possible.""" |
| 67 | |||
| 68 | Handles filenames like 'Chapter 1 - Title.md', 'Chapter 10 - Title.md', etc. | ||
| 69 | Falls back to alphabetical if no chapter number is found. | ||
| 70 | """ | ||
| 71 | name = path.stem | 63 | name = path.stem |
| 72 | # Try to extract chapter number from common patterns | 64 | |
| 73 | match = re.search(r'[Cc]hapter\s+(\d+)', name) | 65 | match = re.search(r"[Cc]hapter\s+(\d+)", name) |
| 74 | if match: | 66 | if match: |
| 75 | return (0, int(match.group(1)), name) | 67 | return (0, int(match.group(1)), name) |
| 76 | # Try plain number at start | 68 | |
| 77 | match = re.match(r'(\d+)', name) | 69 | match = re.match(r"(\d+)", name) |
| 78 | if match: | 70 | if match: |
| 79 | return (0, int(match.group(1)), name) | 71 | return (0, int(match.group(1)), name) |
| 80 | # Fallback: alphabetical, but after numbered chapters | 72 | |
| 81 | return (1, 0, name) | 73 | return (1, 0, name) |
| 82 | 74 | ||
| 83 | 75 | ||
| 84 | def discover_stories(vault_path: Path, creative_folder: str = "Creative") -> list[dict]: | 76 | def discover_stories(vault_path: Path, creative_folder: str = "Creative") -> list[dict]: |
| 85 | """Recursively scan for metadata.md files under the creative folder. | 77 | """Recursively scan for metadata.md files under the creative folder.""" |
| 86 | |||
| 87 | Each metadata.md (with YAML frontmatter) marks a story directory. | ||
| 88 | All other .md files in the same directory (not subfolders) are treated | ||
| 89 | as chapter files. | ||
| 90 | |||
| 91 | Returns a list of story dicts with keys: | ||
| 92 | - path: Path to the story directory | ||
| 93 | - metadata_file: Path to the metadata.md | ||
| 94 | - metadata: Parsed frontmatter dict | ||
| 95 | - metadata_raw: Raw text of the metadata.md file | ||
| 96 | - chapters: List of chapter file Paths, sorted by chapter number | ||
| 97 | """ | ||
| 98 | creative_path = vault_path / creative_folder | 78 | creative_path = vault_path / creative_folder |
| 99 | if not creative_path.exists(): | 79 | if not creative_path.exists(): |
| 100 | logger.warning(f"Creative folder not found: {creative_path}") | 80 | logger.warning(f"Creative folder not found: {creative_path}") |
| @@ -114,31 +94,30 @@ def discover_stories(vault_path: Path, creative_folder: str = "Creative") -> lis | |||
| 114 | logger.warning(f"No valid frontmatter in {metadata_file}, skipping") | 94 | logger.warning(f"No valid frontmatter in {metadata_file}, skipping") |
| 115 | continue | 95 | continue |
| 116 | 96 | ||
| 117 | # Collect .md files in the story directory (not subdirectories) | ||
| 118 | chapters = sorted( | 97 | chapters = sorted( |
| 119 | [f for f in story_dir.iterdir() | 98 | [f for f in story_dir.iterdir() if f.suffix == ".md" and f.name != METADATA_FILENAME], |
| 120 | if f.suffix == ".md" and f.name != METADATA_FILENAME], | 99 | key=natural_sort_key, |
| 121 | key=natural_sort_key | ||
| 122 | ) | 100 | ) |
| 123 | 101 | ||
| 124 | stories.append({ | 102 | stories.append( |
| 103 | { | ||
| 125 | "path": story_dir, | 104 | "path": story_dir, |
| 126 | "metadata_file": metadata_file, | 105 | "metadata_file": metadata_file, |
| 127 | "metadata": metadata, | 106 | "metadata": metadata, |
| 128 | "metadata_raw": raw_text, | 107 | "metadata_raw": raw_text, |
| 129 | "chapters": chapters, | 108 | "chapters": chapters, |
| 130 | }) | 109 | } |
| 131 | logger.info(f"Discovered story: {metadata.get('title', story_dir.name)} " | 110 | ) |
| 132 | f"({len(chapters)} chapters)") | 111 | logger.info( |
| 112 | f"Discovered story: {metadata.get('title', story_dir.name)} " | ||
| 113 | f"({len(chapters)} chapters)" | ||
| 114 | ) | ||
| 133 | 115 | ||
| 134 | return stories | 116 | return stories |
| 135 | 117 | ||
| 136 | 118 | ||
| 137 | def merge_chapters(chapters: list[Path], add_page_breaks: bool = True) -> str: | 119 | def merge_chapters(chapters: list[Path], add_page_breaks: bool = True) -> str: |
| 138 | """Merge chapter markdown files into a single document. | 120 | """Merge chapter markdown files into a single document.""" |
| 139 | |||
| 140 | Inserts pagebreak divs between chapters for pandoc to interpret. | ||
| 141 | """ | ||
| 142 | merged_parts = [] | 121 | merged_parts = [] |
| 143 | 122 | ||
| 144 | for i, chapter_file in enumerate(chapters): | 123 | for i, chapter_file in enumerate(chapters): |
| @@ -149,7 +128,6 @@ def merge_chapters(chapters: list[Path], add_page_breaks: bool = True) -> str: | |||
| 149 | continue | 128 | continue |
| 150 | 129 | ||
| 151 | if i > 0 and add_page_breaks: | 130 | if i > 0 and add_page_breaks: |
| 152 | # Pandoc-compatible page break (works for EPUB and DOCX) | ||
| 153 | merged_parts.append('\n\n<div style="page-break-before: always;"></div>\n\n') | 131 | merged_parts.append('\n\n<div style="page-break-before: always;"></div>\n\n') |
| 154 | 132 | ||
| 155 | merged_parts.append(content) | 133 | merged_parts.append(content) |
| @@ -157,113 +135,166 @@ def merge_chapters(chapters: list[Path], add_page_breaks: bool = True) -> str: | |||
| 157 | return "\n\n".join(merged_parts) | 135 | return "\n\n".join(merged_parts) |
| 158 | 136 | ||
| 159 | 137 | ||
| 160 | def build_epub(story: dict, output_dir: Path, lua_filter: Optional[Path] = None, | 138 | def sanitize_title(title: str) -> str: |
| 161 | css_file: Optional[Path] = None) -> Optional[Path]: | 139 | """Convert a story title into a safe base filename.""" |
| 162 | """Build an EPUB from a story's merged chapters using pandoc. | 140 | return re.sub(r"[^\w\s-]", "", title).strip() |
| 163 | 141 | ||
| 164 | Args: | ||
| 165 | story: Story dict from discover_stories() | ||
| 166 | output_dir: Directory to write the EPUB to | ||
| 167 | lua_filter: Path to the center-v.lua filter | ||
| 168 | css_file: Optional custom CSS file for EPUB styling | ||
| 169 | 142 | ||
| 170 | Returns: | 143 | def get_cover_path(story: dict) -> Optional[Path]: |
| 171 | Path to the generated EPUB, or None on failure. | 144 | """Resolve a configured or auto-detected cover image for the story.""" |
| 172 | """ | 145 | metadata = story["metadata"] |
| 146 | cover = metadata.get("cover") | ||
| 147 | |||
| 148 | if cover: | ||
| 149 | cover_path = story["path"] / cover | ||
| 150 | if cover_path.exists(): | ||
| 151 | return cover_path | ||
| 152 | logger.warning(f"Cover image specified but not found: {cover_path}") | ||
| 153 | return None | ||
| 154 | |||
| 155 | for ext in ["jpg", "jpeg", "png"]: | ||
| 156 | candidate = story["path"] / f"cover.{ext}" | ||
| 157 | if candidate.exists(): | ||
| 158 | return candidate | ||
| 159 | |||
| 160 | return None | ||
| 161 | |||
| 162 | |||
| 163 | def set_output_permissions(*paths: Path): | ||
| 164 | """Set predictable readable permissions on generated output files.""" | ||
| 165 | for path in paths: | ||
| 166 | os.chmod(path, DEFAULT_OUTPUT_FILE_MODE) | ||
| 167 | |||
| 168 | |||
| 169 | def run_command(cmd: list[str], title: str, output_label: str) -> bool: | ||
| 170 | """Run an external command and log any failure details.""" | ||
| 171 | logger.debug(f"Running: {' '.join(cmd)}") | ||
| 172 | |||
| 173 | result = subprocess.run( | ||
| 174 | cmd, | ||
| 175 | capture_output=True, | ||
| 176 | text=True, | ||
| 177 | timeout=240, | ||
| 178 | ) | ||
| 179 | |||
| 180 | if result.returncode != 0: | ||
| 181 | logger.error(f"{output_label} build failed for '{title}':\n{result.stderr}") | ||
| 182 | return False | ||
| 183 | |||
| 184 | if result.stderr: | ||
| 185 | logger.warning(f"{output_label} warnings for '{title}':\n{result.stderr}") | ||
| 186 | |||
| 187 | return True | ||
| 188 | |||
| 189 | |||
| 190 | def build_outputs( | ||
| 191 | story: dict, | ||
| 192 | output_dir: Path, | ||
| 193 | lua_filter: Optional[Path] = None, | ||
| 194 | css_file: Optional[Path] = None, | ||
| 195 | ) -> Optional[dict[str, Path]]: | ||
| 196 | """Build DOCX, EPUB, and MOBI outputs for a story.""" | ||
| 173 | metadata = story["metadata"] | 197 | metadata = story["metadata"] |
| 174 | title = metadata.get("title", story["path"].name) | 198 | title = metadata.get("title", story["path"].name) |
| 175 | author = metadata.get("author", "Unknown Author") | 199 | author = metadata.get("author", "Unknown Author") |
| 176 | language = metadata.get("language", "en") | 200 | language = metadata.get("language", "en") |
| 177 | 201 | ||
| 178 | # Sanitize filename | 202 | safe_title = sanitize_title(title) |
| 179 | safe_title = re.sub(r'[^\w\s-]', '', title).strip() | 203 | epub_dir = output_dir / EPUB_SUBDIR |
| 180 | output_file = output_dir / f"{safe_title}.epub" | 204 | mobi_dir = output_dir / MOBI_SUBDIR |
| 205 | docx_file = output_dir / f"{safe_title}.docx" | ||
| 206 | epub_file = epub_dir / f"{safe_title}.epub" | ||
| 207 | mobi_file = mobi_dir / f"{safe_title}.mobi" | ||
| 181 | output_dir.mkdir(parents=True, exist_ok=True) | 208 | output_dir.mkdir(parents=True, exist_ok=True) |
| 209 | epub_dir.mkdir(parents=True, exist_ok=True) | ||
| 210 | mobi_dir.mkdir(parents=True, exist_ok=True) | ||
| 182 | 211 | ||
| 183 | if not story["chapters"]: | 212 | if not story["chapters"]: |
| 184 | logger.warning(f"No chapters found for '{title}', skipping") | 213 | logger.warning(f"No chapters found for '{title}', skipping") |
| 185 | return None | 214 | return None |
| 186 | 215 | ||
| 187 | logger.info(f"Building EPUB: '{title}' ({len(story['chapters'])} chapters)") | 216 | logger.info(f"Building book files: '{title}' ({len(story['chapters'])} chapters)") |
| 188 | 217 | ||
| 189 | # Merge all chapters into a single markdown string | ||
| 190 | merged_content = merge_chapters(story["chapters"]) | 218 | merged_content = merge_chapters(story["chapters"]) |
| 219 | cover_path = get_cover_path(story) | ||
| 191 | 220 | ||
| 192 | # Write merged content to a temp file for pandoc | 221 | with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False, encoding="utf-8") as tmp: |
| 193 | with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False, | ||
| 194 | encoding="utf-8") as tmp: | ||
| 195 | tmp.write(merged_content) | 222 | tmp.write(merged_content) |
| 196 | tmp_path = tmp.name | 223 | tmp_path = tmp.name |
| 197 | 224 | ||
| 198 | try: | 225 | try: |
| 199 | # Build pandoc command | 226 | docx_cmd = [ |
| 200 | cmd = [ | ||
| 201 | "pandoc", | 227 | "pandoc", |
| 202 | tmp_path, | 228 | tmp_path, |
| 203 | "-o", str(output_file), | 229 | "-o", |
| 204 | "--from", "markdown+raw_html", | 230 | str(docx_file), |
| 205 | "--to", "epub3", | 231 | "--from", |
| 206 | "--metadata", f"title={title}", | 232 | "markdown+raw_html", |
| 207 | "--metadata", f"author={author}", | 233 | "--to", |
| 208 | "--metadata", f"lang={language}", | 234 | "docx", |
| 235 | "--metadata", | ||
| 236 | f"title={title}", | ||
| 237 | "--metadata", | ||
| 238 | f"author={author}", | ||
| 239 | "--metadata", | ||
| 240 | f"lang={language}", | ||
| 209 | "--toc", | 241 | "--toc", |
| 210 | "--toc-depth=1", | 242 | "--toc-depth=1", |
| 211 | "--split-level=1", | ||
| 212 | "--wrap=none", | 243 | "--wrap=none", |
| 213 | ] | 244 | ] |
| 214 | 245 | ||
| 215 | # Add cover image: use metadata field if set, otherwise auto-detect | 246 | if lua_filter and lua_filter.exists(): |
| 216 | cover = metadata.get("cover") | 247 | docx_cmd.extend(["--lua-filter", str(lua_filter)]) |
| 217 | cover_path = None | 248 | |
| 218 | if cover: | 249 | if not run_command(docx_cmd, title, "DOCX"): |
| 219 | cover_path = story["path"] / cover | 250 | return None |
| 220 | if not cover_path.exists(): | 251 | logger.info(f"DOCX created: {docx_file}") |
| 221 | logger.warning(f"Cover image specified but not found: {cover_path}") | 252 | |
| 222 | cover_path = None | 253 | base_convert_cmd = [ |
| 223 | else: | 254 | "--title", |
| 224 | # Auto-detect cover.jpg / cover.png in the story folder | 255 | title, |
| 225 | for ext in ["jpg", "jpeg", "png"]: | 256 | "--authors", |
| 226 | candidate = story["path"] / f"cover.{ext}" | 257 | author, |
| 227 | if candidate.exists(): | 258 | "--language", |
| 228 | cover_path = candidate | 259 | language, |
| 229 | break | 260 | "--chapter", |
| 261 | "//h:h1|//h:h2", | ||
| 262 | "--level1-toc", | ||
| 263 | "//h:h1", | ||
| 264 | "--level2-toc", | ||
| 265 | "//h:h2", | ||
| 266 | "--max-toc-links", | ||
| 267 | "500", | ||
| 268 | ] | ||
| 269 | |||
| 270 | epub_cmd = ["ebook-convert", str(docx_file), str(epub_file), *base_convert_cmd] | ||
| 271 | mobi_cmd = ["ebook-convert", str(docx_file), str(mobi_file), *base_convert_cmd] | ||
| 272 | |||
| 230 | if cover_path: | 273 | if cover_path: |
| 231 | cmd.extend(["--epub-cover-image", str(cover_path)]) | ||
| 232 | logger.info(f"Using cover image: {cover_path.name}") | 274 | logger.info(f"Using cover image: {cover_path.name}") |
| 275 | epub_cmd.extend(["--cover", str(cover_path)]) | ||
| 276 | mobi_cmd.extend(["--cover", str(cover_path)]) | ||
| 233 | 277 | ||
| 234 | # Add custom CSS | ||
| 235 | if css_file and css_file.exists(): | 278 | if css_file and css_file.exists(): |
| 236 | cmd.extend(["--css", str(css_file)]) | 279 | epub_cmd.extend(["--extra-css", str(css_file)]) |
| 237 | 280 | mobi_cmd.extend(["--extra-css", str(css_file)]) | |
| 238 | # Add lua filter for ~V~ handling | ||
| 239 | if lua_filter and lua_filter.exists(): | ||
| 240 | cmd.extend(["--lua-filter", str(lua_filter)]) | ||
| 241 | 281 | ||
| 242 | logger.debug(f"Running: {' '.join(cmd)}") | 282 | if not run_command(epub_cmd, title, "EPUB"): |
| 243 | result = subprocess.run( | ||
| 244 | cmd, | ||
| 245 | capture_output=True, | ||
| 246 | text=True, | ||
| 247 | timeout=120, | ||
| 248 | ) | ||
| 249 | |||
| 250 | if result.returncode != 0: | ||
| 251 | logger.error(f"Pandoc failed for '{title}':\n{result.stderr}") | ||
| 252 | return None | 283 | return None |
| 284 | logger.info(f"EPUB created: {epub_file}") | ||
| 253 | 285 | ||
| 254 | if result.stderr: | 286 | if not run_command(mobi_cmd, title, "MOBI"): |
| 255 | logger.warning(f"Pandoc warnings for '{title}':\n{result.stderr}") | 287 | return None |
| 288 | logger.info(f"MOBI created: {mobi_file}") | ||
| 256 | 289 | ||
| 257 | # Keep generated books readable to any user sharing the mounted output. | 290 | set_output_permissions(docx_file, epub_file, mobi_file) |
| 258 | os.chmod(output_file, DEFAULT_OUTPUT_FILE_MODE) | 291 | return {"docx": docx_file, "epub": epub_file, "mobi": mobi_file} |
| 259 | logger.info(f"EPUB created: {output_file}") | ||
| 260 | return output_file | ||
| 261 | 292 | ||
| 262 | except subprocess.TimeoutExpired: | 293 | except subprocess.TimeoutExpired: |
| 263 | logger.error(f"Pandoc timed out for '{title}'") | 294 | logger.error(f"Build timed out for '{title}'") |
| 264 | return None | ||
| 265 | except Exception as e: | 295 | except Exception as e: |
| 266 | logger.error(f"EPUB build failed for '{title}': {e}") | 296 | logger.error(f"Build failed for '{title}': {e}") |
| 267 | return None | ||
| 268 | finally: | 297 | finally: |
| 269 | Path(tmp_path).unlink(missing_ok=True) | 298 | Path(tmp_path).unlink(missing_ok=True) |
| 299 | |||
| 300 | return None | ||
| @@ -1,14 +1,10 @@ | |||
| 1 | #!/usr/bin/env python3 | 1 | #!/usr/bin/env python3 |
| 2 | """ | 2 | """ |
| 3 | Obsidian EPUB Exporter | 3 | Obsidian ebook exporter. |
| 4 | 4 | ||
| 5 | Watches a mounted Obsidian vault for metadata.md files with `export: true` | 5 | Watches a mounted Obsidian vault for metadata.md files with `export: true` |
| 6 | in their YAML frontmatter. When found, merges all chapter .md files in that | 6 | in their YAML frontmatter. When found, it builds DOCX, EPUB, and MOBI files |
| 7 | folder into a single EPUB via pandoc, and writes it to the mounted output folder. | 7 | in the mounted output folder, then resets the flag back to false. |
| 8 | and resets the flag back to false. | ||
| 9 | |||
| 10 | Designed to run as a small polling container with the source markdown share | ||
| 11 | and destination EPUB share mounted into it. | ||
| 12 | """ | 8 | """ |
| 13 | 9 | ||
| 14 | import logging | 10 | import logging |
| @@ -17,7 +13,7 @@ import sys | |||
| 17 | import time | 13 | import time |
| 18 | from pathlib import Path | 14 | from pathlib import Path |
| 19 | 15 | ||
| 20 | from epub_builder import build_epub, discover_stories, write_frontmatter | 16 | from epub_builder import build_outputs, discover_stories, write_frontmatter |
| 21 | 17 | ||
| 22 | # ── Configuration from environment variables ────────────────────────── | 18 | # ── Configuration from environment variables ────────────────────────── |
| 23 | 19 | ||
| @@ -62,7 +58,7 @@ def reset_export_flag(story: dict): | |||
| 62 | 58 | ||
| 63 | 59 | ||
| 64 | def process_exports(vault_path: Path): | 60 | def process_exports(vault_path: Path): |
| 65 | """Scan for stories with export: true and build EPUBs.""" | 61 | """Scan for stories with export: true and build ebook outputs.""" |
| 66 | stories = discover_stories(vault_path, CREATIVE_FOLDER) | 62 | stories = discover_stories(vault_path, CREATIVE_FOLDER) |
| 67 | to_export = [s for s in stories if s["metadata"].get("export") is True] | 63 | to_export = [s for s in stories if s["metadata"].get("export") is True] |
| 68 | 64 | ||
| @@ -77,15 +73,16 @@ def process_exports(vault_path: Path): | |||
| 77 | title = story["metadata"].get("title", story["path"].name) | 73 | title = story["metadata"].get("title", story["path"].name) |
| 78 | logger.info(f"Export requested for: '{title}'") | 74 | logger.info(f"Export requested for: '{title}'") |
| 79 | 75 | ||
| 80 | epub_path = build_epub( | 76 | built_files = build_outputs( |
| 81 | story=story, | 77 | story=story, |
| 82 | output_dir=output_path, | 78 | output_dir=output_path, |
| 83 | lua_filter=lua_filter, | 79 | lua_filter=lua_filter, |
| 84 | css_file=css_file if css_file and css_file.exists() else None, | 80 | css_file=css_file if css_file and css_file.exists() else None, |
| 85 | ) | 81 | ) |
| 86 | 82 | ||
| 87 | if epub_path: | 83 | if built_files: |
| 88 | logger.info(f"Successfully exported: {epub_path}") | 84 | created = ", ".join(str(path.name) for path in built_files.values()) |
| 85 | logger.info(f"Successfully exported: {created}") | ||
| 89 | reset_export_flag(story) | 86 | reset_export_flag(story) |
| 90 | else: | 87 | else: |
| 91 | logger.error(f"Failed to export '{title}'") | 88 | logger.error(f"Failed to export '{title}'") |
| @@ -93,7 +90,7 @@ def process_exports(vault_path: Path): | |||
| 93 | 90 | ||
| 94 | def main(): | 91 | def main(): |
| 95 | logger.info("=" * 60) | 92 | logger.info("=" * 60) |
| 96 | logger.info("Obsidian EPUB Exporter starting up") | 93 | logger.info("Obsidian ebook exporter starting up") |
| 97 | logger.info("=" * 60) | 94 | logger.info("=" * 60) |
| 98 | logger.info(f"Vault path: {VAULT_PATH}") | 95 | logger.info(f"Vault path: {VAULT_PATH}") |
| 99 | logger.info(f"Creative folder: {CREATIVE_FOLDER}") | 96 | logger.info(f"Creative folder: {CREATIVE_FOLDER}") |