v2 - docx and convert

5c0a85854a32a7593a2a8b6bac4a24efa3f6d353

permissionBRICK <you@example.com>

4 files changed, +173 -144Ignore whitespace
Dockerfile+1 -1
@@ -1,7 +1,7 @@
11FROM python:3.12-slim
22
33RUN apt-get update && \
44 apt-get install -y --no-install-recommends pandoc calibre && \
55 rm -rf /var/lib/apt/lists/*
66
77RUN mkdir -p /app /output
README.md+7 -6
@@ -1,14 +1,15 @@
11# Obsidian Markdown to EPUBEbook Pipeline
22
33This container watches a mounted markdown share for story folders and generates DOCX, EPUB, and MOBI files into a separate mounted output folder.
44
55## How it works
66
771. The container scans the mounted vault under `/vault/<CREATIVE_FOLDER>`.
882. Each story folder is identified by a `metadata.md` file with YAML frontmatter.
993. If that frontmatter contains `export: true`, the container merges the sibling chapter `.md` files in that folder and converts themexports toa EPUBDOCX 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`.
1213
1314## Story layout
1415
@@ -59,7 +60,7 @@ docker compose logs -f
5960| `VAULT_PATH` | `/vault` | Mounted source folder |
6061| `OUTPUT_DIR` | `/output` | Mounted output folder |
6162| `LUA_FILTER_PATH` | `/app/center-v.lua` | Pandoc Lua filter |
6263| `CSS_FILE_PATH` | `/app/epub-style.css` | EPUBExtra stylesheet used during EPUB and MOBI conversion |
6364
6465## Docker Hub publishing flow
6566
epub_builder.py+155 -124
@@ -1,9 +1,9 @@
11"""
22Story discovery and EPUBebook build pipeline.
33
44Scans the vault for metadata.md files (with YAML frontmatter), discovers
55chapter files, merges them into a single markdown document, andexports convertsa DOCX
6-to EPUB via pandoc.
6+via pandoc, then converts that DOCX to EPUB and MOBI.
77"""
88
99import logging
@@ -20,21 +20,20 @@ logger = logging.getLogger(__name__)
2020
2121METADATA_FILENAME = "metadata.md"
2222DEFAULT_OUTPUT_FILE_MODE = 0o644
23+EPUB_SUBDIR = "epub"
24+MOBI_SUBDIR = "mobi"
2325
2426
2527def parse_frontmatter(text: str) -> dict:
2628 """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- """
3129 text = text.strip()
3230 if not text.startswith("---"):
3331 return {}
34- # Find the closing ---
32+
3533 end = text.find("---", 3)
3634 if end == -1:
3735 return {}
36+
3837 yaml_block = text[3:end].strip()
3938 try:
4039 return yaml.safe_load(yaml_block) or {}
@@ -44,10 +43,7 @@ def parse_frontmatter(text: str) -> dict:
4443
4544
4645def write_frontmatter(metadata: dict, original_text: str) -> str:
4746 """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- """
5147 body = ""
5248 original_text = original_text.strip()
5349 if original_text.startswith("---"):
@@ -63,38 +59,22 @@ def write_frontmatter(metadata: dict, original_text: str) -> str:
6359
6460
6561def natural_sort_key(path: Path) -> tuple:
6662 """Sort chapter files by their chapter number naturallywhen 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- """
7163 name = path.stem
72- # Try to extract chapter number from common patterns
64+
7365 match = re.search(r'"[Cc]hapter\s+(\d+)'", name)
7466 if match:
7567 return (0, int(match.group(1)), name)
76- # Try plain number at start
68+
7769 match = re.match(r'"(\d+)'", name)
7870 if match:
7971 return (0, int(match.group(1)), name)
80- # Fallback: alphabetical, but after numbered chapters
72+
8173 return (1, 0, name)
8274
8375
8476def discover_stories(vault_path: Path, creative_folder: str = "Creative") -> list[dict]:
8577 """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- """
9878 creative_path = vault_path / creative_folder
9979 if not creative_path.exists():
10080 logger.warning(f"Creative folder not found: {creative_path}")
@@ -114,31 +94,30 @@ def discover_stories(vault_path: Path, creative_folder: str = "Creative") -> lis
11494 logger.warning(f"No valid frontmatter in {metadata_file}, skipping")
11595 continue
11696
117- # Collect .md files in the story directory (not subdirectories)
11897 chapters = sorted(
11998 [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
122100 )
123101
124102 stories.append({
125- "path": story_dir,
103+ {
126104 "metadata_filepath": metadata_filestory_dir,
127105 "metadatametadata_file": metadatametadata_file,
128106 "metadata_rawmetadata": raw_textmetadata,
129107 "chaptersmetadata_raw": chaptersraw_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
134116 return stories
135117
136118
137119def merge_chapters(chapters: list[Path], add_page_breaks: bool = True) -> str:
138120 """Merge chapter markdown files into a single document."""
139-
140- Inserts pagebreak divs between chapters for pandoc to interpret.
141- """
142121 merged_parts = []
143122
144123 for i, chapter_file in enumerate(chapters):
@@ -149,7 +128,6 @@ def merge_chapters(chapters: list[Path], add_page_breaks: bool = True) -> str:
149128 continue
150129
151130 if i > 0 and add_page_breaks:
152- # Pandoc-compatible page break (works for EPUB and DOCX)
153131 merged_parts.append('\n\n<div style="page-break-before: always;"></div>\n\n')
154132
155133 merged_parts.append(content)
@@ -157,113 +135,166 @@ def merge_chapters(chapters: list[Path], add_page_breaks: bool = True) -> str:
157135 return "\n\n".join(merged_parts)
158136
159137
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()
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:
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."""
173197 metadata = story["metadata"]
174198 title = metadata.get("title", story["path"].name)
175199 author = metadata.get("author", "Unknown Author")
176200 language = metadata.get("language", "en")
177201
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
180204 output_filemobi_dir = output_dir / f"{safe_title}.epub"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"
181208 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
183212 if not story["chapters"]:
184213 logger.warning(f"No chapters found for '{title}', skipping")
185214 return None
186215
187216 logger.info(f"Building EPUBbook files: '{title}' ({len(story['chapters'])} chapters)")
188217
189- # Merge all chapters into a single markdown string
190218 merged_content = merge_chapters(story["chapters"])
219+ cover_path = get_cover_path(story)
191220
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:
195222 tmp.write(merged_content)
196223 tmp_path = tmp.name
197224
198225 try:
199- # Build pandoc command
226+ docx_cmd = [
200- cmd = [
201227 "pandoc",
202228 tmp_path,
203229 "-o", str(output_file),
204- "--from", "markdown+raw_html",
230+ str(docx_file),
205231 "--to", "epub3from",
206- "--metadata", f"title={title}",
232+ "markdown+raw_html",
207233 "--metadata", f"author={author}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}",
209241 "--toc",
210242 "--toc-depth=1",
211- "--split-level=1",
212243 "--wrap=none",
213244 ]
214245
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+
222253 cover_path base_convert_cmd = None[
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+
230273 if cover_path:
231- cmd.extend(["--epub-cover-image", str(cover_path)])
232274 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
235278 if css_file and css_file.exists():
236279 cmdepub_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-
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}")
252283 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
262293 except subprocess.TimeoutExpired:
263294 logger.error(f"PandocBuild timed out for '{title}'")
264- return None
265295 except Exception as e:
266296 logger.error(f"EPUB buildBuild failed for '{title}': {e}")
267- return None
268297 finally:
269298 Path(tmp_path).unlink(missing_ok=True)
299+
300+ return None
main.py+10 -13
@@ -1,14 +1,10 @@
11#!/usr/bin/env python3
22"""
33Obsidian EPUBebook Exporterexporter.
44
55Watches a mounted Obsidian vault for metadata.md files with `export: true`
66in their YAML frontmatter. When found, mergesit allbuilds chapterDOCX, .mdEPUB, filesand inMOBI thatfiles
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.
128"""
139
1410import logging
@@ -17,7 +13,7 @@ import sys
1713import time
1814from pathlib import Path
1915
2016from epub_builder import build_epubbuild_outputs, discover_stories, write_frontmatter
2117
2218# ── Configuration from environment variables ──────────────────────────
2319
@@ -62,7 +58,7 @@ def reset_export_flag(story: dict):
6258
6359
6460def process_exports(vault_path: Path):
6561 """Scan for stories with export: true and build EPUBsebook outputs."""
6662 stories = discover_stories(vault_path, CREATIVE_FOLDER)
6763 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):
7773 title = story["metadata"].get("title", story["path"].name)
7874 logger.info(f"Export requested for: '{title}'")
7975
8076 epub_pathbuilt_files = build_epubbuild_outputs(
8177 story=story,
8278 output_dir=output_path,
8379 lua_filter=lua_filter,
8480 css_file=css_file if css_file and css_file.exists() else None,
8581 )
8682
8783 if epub_pathbuilt_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}")
8986 reset_export_flag(story)
9087 else:
9188 logger.error(f"Failed to export '{title}'")
@@ -93,7 +90,7 @@ def process_exports(vault_path: Path):
9390
9491def main():
9592 logger.info("=" * 60)
9693 logger.info("Obsidian EPUBebook Exporterexporter starting up")
9794 logger.info("=" * 60)
9895 logger.info(f"Vault path: {VAULT_PATH}")
9996 logger.info(f"Creative folder: {CREATIVE_FOLDER}")