v2 - docx and convert

5c0a85854a32a7593a2a8b6bac4a24efa3f6d353

permissionBRICK <you@example.com>

4 files changed, +173 -144Ignore whitespace
Dockerfile+1 -1
@@ -1,7 +1,7 @@
1FROM python:3.12-slim1FROM python:3.12-slim
22
3RUN apt-get update && \3RUN 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/*
66
7RUN mkdir -p /app /output7RUN mkdir -p /app /output
README.md+7 -6
@@ -1,14 +1,15 @@
1# Obsidian Markdown to EPUB Pipeline1# Obsidian Markdown to Ebook Pipeline
22
3This container watches a mounted markdown share for story folders and generates EPUB files into a separate mounted output folder.3This container watches a mounted markdown share for story folders and generates DOCX, EPUB, and MOBI files into a separate mounted output folder.
44
5## How it works5## How it works
66
71. The container scans the mounted vault under `/vault/<CREATIVE_FOLDER>`.71. The container scans the mounted vault under `/vault/<CREATIVE_FOLDER>`.
82. Each story folder is identified by a `metadata.md` file with YAML frontmatter.82. Each story folder is identified by a `metadata.md` file with YAML frontmatter.
93. If that frontmatter contains `export: true`, the container merges the sibling chapter `.md` files in that folder and converts them to EPUB with `pandoc`.93. If that frontmatter contains `export: true`, the container merges the sibling chapter `.md` files in that folder and exports a DOCX with `pandoc`.
104. The finished EPUB is written to `/output` and explicitly set to mode `0644` so it is readable by all users.104. That DOCX is then converted into EPUB and MOBI with Calibre's `ebook-convert`.
115. The container resets `export: false` in the story's `metadata.md`.115. 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.
126. The container resets `export: false` in the story's `metadata.md`.
1213
13## Story layout14## Story layout
1415
@@ -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 |
6364
64## Docker Hub publishing flow65## Docker Hub publishing flow
6566
epub_builder.py+155 -124
@@ -1,9 +1,9 @@
1"""1"""
2Story discovery and EPUB build pipeline.2Story discovery and ebook build pipeline.
33
4Scans the vault for metadata.md files (with YAML frontmatter), discovers4Scans the vault for metadata.md files (with YAML frontmatter), discovers
5chapter files, merges them into a single markdown document, and converts5chapter files, merges them into a single markdown document, exports a DOCX
6to EPUB via pandoc.6via pandoc, then converts that DOCX to EPUB and MOBI.
7"""7"""
88
9import logging9import logging
@@ -20,21 +20,20 @@ logger = logging.getLogger(__name__)
2020
21METADATA_FILENAME = "metadata.md"21METADATA_FILENAME = "metadata.md"
22DEFAULT_OUTPUT_FILE_MODE = 0o64422DEFAULT_OUTPUT_FILE_MODE = 0o644
23EPUB_SUBDIR = "epub"
24MOBI_SUBDIR = "mobi"
2325
2426
25def parse_frontmatter(text: str) -> dict:27def 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:
4443
4544
46def write_frontmatter(metadata: dict, original_text: str) -> str:45def 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:
6359
6460
65def natural_sort_key(path: Path) -> tuple:61def 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.stem63 name = path.stem
72 # Try to extract chapter number from common patterns64
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 start68
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 chapters72
81 return (1, 0, name)73 return (1, 0, name)
8274
8375
84def discover_stories(vault_path: Path, creative_folder: str = "Creative") -> list[dict]:76def 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_folder78 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 continue95 continue
11696
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 )
123101
124 stories.append({102 stories.append(
125 "path": story_dir,103 {
126 "metadata_file": metadata_file,104 "path": story_dir,
127 "metadata": metadata,105 "metadata_file": metadata_file,
128 "metadata_raw": raw_text,106 "metadata": metadata,
129 "chapters": chapters,107 "metadata_raw": raw_text,
130 })108 "chapters": chapters,
131 logger.info(f"Discovered story: {metadata.get('title', story_dir.name)} "109 }
132 f"({len(chapters)} chapters)")110 )
111 logger.info(
112 f"Discovered story: {metadata.get('title', story_dir.name)} "
113 f"({len(chapters)} chapters)"
114 )
133115
134 return stories116 return stories
135117
136118
137def merge_chapters(chapters: list[Path], add_page_breaks: bool = True) -> str:119def 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 = []
143122
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 continue128 continue
150129
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')
154132
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)
158136
159137
160def build_epub(story: dict, output_dir: Path, lua_filter: Optional[Path] = None,138def 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()
163141
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
169142
170 Returns:143def 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
163def 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
169def 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
190def 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")
177201
178 # Sanitize filename202 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)
182211
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 None214 return None
186215
187 logger.info(f"Building EPUB: '{title}' ({len(story['chapters'])} chapters)")216 logger.info(f"Building book files: '{title}' ({len(story['chapters'])} chapters)")
188217
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)
191220
192 # Write merged content to a temp file for pandoc221 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.name223 tmp_path = tmp.name
197224
198 try:225 try:
199 # Build pandoc command226 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 ]
214245
215 # Add cover image: use metadata field if set, otherwise auto-detect246 if lua_filter and lua_filter.exists():
216 cover = metadata.get("cover")247 docx_cmd.extend(["--lua-filter", str(lua_filter)])
217 cover_path = None248
218 if cover:249 if not run_command(docx_cmd, title, "DOCX"):
219 cover_path = story["path"] / cover250 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 = None253 base_convert_cmd = [
223 else:254 "--title",
224 # Auto-detect cover.jpg / cover.png in the story folder255 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 = candidate259 language,
229 break260 "--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)])
233277
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)])
237280 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
242 logger.debug(f"Running: {' '.join(cmd)}")
243 result = subprocess.run(
244 cmd,
245 capture_output=True,
246 text=True,
247 timeout=120,
248 )
249281
250 if result.returncode != 0:282 if not run_command(epub_cmd, title, "EPUB"):
251 logger.error(f"Pandoc failed for '{title}':\n{result.stderr}")
252 return None283 return None
284 logger.info(f"EPUB created: {epub_file}")
253285
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}")
256289
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
261292
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
main.py+10 -13
@@ -1,14 +1,10 @@
1#!/usr/bin/env python31#!/usr/bin/env python3
2"""2"""
3Obsidian EPUB Exporter3Obsidian ebook exporter.
44
5Watches a mounted Obsidian vault for metadata.md files with `export: true`5Watches a mounted Obsidian vault for metadata.md files with `export: true`
6in their YAML frontmatter. When found, merges all chapter .md files in that6in their YAML frontmatter. When found, it builds DOCX, EPUB, and MOBI files
7folder into a single EPUB via pandoc, and writes it to the mounted output folder.7in the mounted output folder, then resets the flag back to false.
8and resets the flag back to false.
9
10Designed to run as a small polling container with the source markdown share
11and destination EPUB share mounted into it.
12"""8"""
139
14import logging10import logging
@@ -17,7 +13,7 @@ import sys
17import time13import time
18from pathlib import Path14from pathlib import Path
1915
20from epub_builder import build_epub, discover_stories, write_frontmatter16from epub_builder import build_outputs, discover_stories, write_frontmatter
2117
22# ── Configuration from environment variables ──────────────────────────18# ── Configuration from environment variables ──────────────────────────
2319
@@ -62,7 +58,7 @@ def reset_export_flag(story: dict):
6258
6359
64def process_exports(vault_path: Path):60def 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]
6864
@@ -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}'")
7975
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 )
8682
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):
9390
94def main():91def 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}")