initial commit
| @@ -0,0 +1 @@ | ||
| 1 | +/__pycache__ | |
| 1 | \ No newline at end of file | |
| @@ -0,0 +1,22 @@ | ||
| 1 | +FROM python:3.12-slim | |
| 2 | + | |
| 3 | +RUN apt-get update && \ | |
| 4 | + apt-get install -y --no-install-recommends pandoc && \ | |
| 5 | + rm -rf /var/lib/apt/lists/* | |
| 6 | + | |
| 7 | +RUN mkdir -p /app /output | |
| 8 | + | |
| 9 | +WORKDIR /app | |
| 10 | + | |
| 11 | +COPY requirements.txt /app/ | |
| 12 | +RUN pip install --no-cache-dir -r requirements.txt | |
| 13 | + | |
| 14 | +COPY main.py epub_builder.py /app/ | |
| 15 | +COPY center-v.lua /app/ | |
| 16 | +COPY epub-style.css /app/ | |
| 17 | + | |
| 18 | +# /vault - mount your markdown source folder here (read-write for flag reset) | |
| 19 | +# /output - mount your EPUB destination folder here | |
| 20 | +VOLUME ["/vault", "/output"] | |
| 21 | + | |
| 22 | +CMD ["python", "-u", "main.py"] | |
| @@ -0,0 +1,81 @@ | ||
| 1 | +# Obsidian Markdown to EPUB Pipeline | |
| 2 | + | |
| 3 | +This container watches a mounted markdown share for story folders and generates EPUB files into a separate mounted output folder. | |
| 4 | + | |
| 5 | +## How it works | |
| 6 | + | |
| 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. | |
| 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`. | |
| 10 | +4. The finished EPUB is written to `/output` and explicitly set to mode `0644` so it is readable by all users. | |
| 11 | +5. The container resets `export: false` in the story's `metadata.md`. | |
| 12 | + | |
| 13 | +## Story layout | |
| 14 | + | |
| 15 | +Only markdown files directly inside the story folder are treated as chapters. Subfolders are ignored. | |
| 16 | + | |
| 17 | +```text | |
| 18 | +Creative/ | |
| 19 | + Novel Name/ | |
| 20 | + metadata.md | |
| 21 | + Chapter 1 - Opening.md | |
| 22 | + Chapter 2 - Next Scene.md | |
| 23 | + cover.jpg | |
| 24 | +``` | |
| 25 | + | |
| 26 | +Example `metadata.md`: | |
| 27 | + | |
| 28 | +```md | |
| 29 | +--- | |
| 30 | +title: "Novel Name" | |
| 31 | +author: "Your Name" | |
| 32 | +language: en | |
| 33 | +export: false | |
| 34 | +# cover: cover.jpg | |
| 35 | +--- | |
| 36 | +``` | |
| 37 | + | |
| 38 | +## Run with Docker Compose | |
| 39 | + | |
| 40 | +Update [`docker-compose.yml`](/Users/DevUser/Desktop/SyncNotes/obsidian-epub-pipeline/docker-compose.yml) to point at your real folders, then run: | |
| 41 | + | |
| 42 | +```bash | |
| 43 | +docker compose up -d --build | |
| 44 | +``` | |
| 45 | + | |
| 46 | +Watch logs with: | |
| 47 | + | |
| 48 | +```bash | |
| 49 | +docker compose logs -f | |
| 50 | +``` | |
| 51 | + | |
| 52 | +## Environment | |
| 53 | + | |
| 54 | +| Variable | Default | Description | | |
| 55 | +|---|---|---| | |
| 56 | +| `CREATIVE_FOLDER` | `Creative` | Subfolder under `/vault` to scan | | |
| 57 | +| `POLL_INTERVAL` | `10` | Seconds between scans | | |
| 58 | +| `LOG_LEVEL` | `INFO` | Python logging level | | |
| 59 | +| `VAULT_PATH` | `/vault` | Mounted source folder | | |
| 60 | +| `OUTPUT_DIR` | `/output` | Mounted output folder | | |
| 61 | +| `LUA_FILTER_PATH` | `/app/center-v.lua` | Pandoc Lua filter | | |
| 62 | +| `CSS_FILE_PATH` | `/app/epub-style.css` | EPUB stylesheet | | |
| 63 | + | |
| 64 | +## Docker Hub publishing flow | |
| 65 | + | |
| 66 | +Build and test locally first: | |
| 67 | + | |
| 68 | +```bash | |
| 69 | +docker build -t YOUR_DOCKERHUB_USERNAME/obsidian-epub-pipeline:latest . | |
| 70 | +docker run --rm ^ | |
| 71 | + -v /path/to/markdown/share:/vault ^ | |
| 72 | + -v /path/to/epub/output:/output ^ | |
| 73 | + YOUR_DOCKERHUB_USERNAME/obsidian-epub-pipeline:latest | |
| 74 | +``` | |
| 75 | + | |
| 76 | +Then log in and push: | |
| 77 | + | |
| 78 | +```bash | |
| 79 | +docker login | |
| 80 | +docker push YOUR_DOCKERHUB_USERNAME/obsidian-epub-pipeline:latest | |
| 81 | +``` | |
| @@ -0,0 +1,87 @@ | ||
| 1 | +-- center-v.lua | |
| 2 | +-- Pandoc Lua filter for converting ~V~ section break symbols | |
| 3 | +-- into properly centered paragraphs in EPUB/DOCX/HTML output. | |
| 4 | +-- | |
| 5 | +-- Handles multiple input patterns that Obsidian might produce: | |
| 6 | +-- 1. <center>\n~V~\n</center> (HTML blocks wrapping subscript) | |
| 7 | +-- 2. Raw ~V~ as a paragraph | |
| 8 | +-- 3. The tilde-based subscript interpretation (~V~ → subscript V) | |
| 9 | + | |
| 10 | +local function make_centered() | |
| 11 | + if FORMAT == "epub" or FORMAT == "epub3" or FORMAT == "html" or FORMAT == "html5" then | |
| 12 | + return pandoc.RawBlock("html", '<p style="text-align: center;">~V~</p>') | |
| 13 | + elseif FORMAT == "docx" or FORMAT == "openxml" then | |
| 14 | + return pandoc.RawBlock("openxml", [[ | |
| 15 | +<w:p> | |
| 16 | + <w:pPr><w:jc w:val="center"/></w:pPr> | |
| 17 | + <w:r><w:t xml:space="preserve">~V~</w:t></w:r> | |
| 18 | +</w:p> | |
| 19 | +]]) | |
| 20 | + else | |
| 21 | + return pandoc.Para({pandoc.Str("~V~")}) | |
| 22 | + end | |
| 23 | +end | |
| 24 | + | |
| 25 | +function Pandoc(doc) | |
| 26 | + local newblocks = {} | |
| 27 | + local i = 1 | |
| 28 | + while i <= #doc.blocks do | |
| 29 | + local b1 = doc.blocks[i] | |
| 30 | + local b2 = doc.blocks[i+1] | |
| 31 | + local b3 = doc.blocks[i+2] | |
| 32 | + | |
| 33 | + -- Pattern 1: <center> block + subscript V + </center> block | |
| 34 | + if b1 and b2 and b3 | |
| 35 | + and b1.t == "RawBlock" and b1.format == "html" and b1.text:match("^<center>%s*$") | |
| 36 | + and b2.t == "Plain" | |
| 37 | + and #b2.content == 1 | |
| 38 | + and b2.content[1].t == "Subscript" | |
| 39 | + and #b2.content[1].content == 1 | |
| 40 | + and b2.content[1].content[1].t == "Str" | |
| 41 | + and b2.content[1].content[1].text == "V" | |
| 42 | + and b3.t == "RawBlock" and b3.format == "html" and b3.text:match("^</center>%s*$") | |
| 43 | + then | |
| 44 | + table.insert(newblocks, make_centered()) | |
| 45 | + i = i + 3 | |
| 46 | + | |
| 47 | + -- Pattern 2: Single paragraph containing just "~V~" | |
| 48 | + elseif b1 and b1.t == "Para" | |
| 49 | + and #b1.content == 1 | |
| 50 | + and b1.content[1].t == "Str" | |
| 51 | + and b1.content[1].text == "~V~" | |
| 52 | + then | |
| 53 | + table.insert(newblocks, make_centered()) | |
| 54 | + i = i + 1 | |
| 55 | + | |
| 56 | + -- Pattern 3: Plain block with subscript V (standalone, no center tags) | |
| 57 | + elseif b1 and b1.t == "Plain" | |
| 58 | + and #b1.content == 1 | |
| 59 | + and b1.content[1].t == "Subscript" | |
| 60 | + and #b1.content[1].content == 1 | |
| 61 | + and b1.content[1].content[1].t == "Str" | |
| 62 | + and b1.content[1].content[1].text == "V" | |
| 63 | + then | |
| 64 | + table.insert(newblocks, make_centered()) | |
| 65 | + i = i + 1 | |
| 66 | + | |
| 67 | + -- Pattern 4: Para with Str "~" + Subscript "V" + Str "~" (tilde interpreted separately) | |
| 68 | + elseif b1 and b1.t == "Para" | |
| 69 | + and #b1.content == 3 | |
| 70 | + and b1.content[1].t == "Str" and b1.content[1].text == "~" | |
| 71 | + and b1.content[2].t == "Subscript" | |
| 72 | + and #b1.content[2].content == 1 | |
| 73 | + and b1.content[2].content[1].t == "Str" | |
| 74 | + and b1.content[2].content[1].text == "V" | |
| 75 | + and b1.content[3].t == "Str" and b1.content[3].text == "~" | |
| 76 | + then | |
| 77 | + table.insert(newblocks, make_centered()) | |
| 78 | + i = i + 1 | |
| 79 | + | |
| 80 | + else | |
| 81 | + table.insert(newblocks, b1) | |
| 82 | + i = i + 1 | |
| 83 | + end | |
| 84 | + end | |
| 85 | + doc.blocks = newblocks | |
| 86 | + return doc | |
| 87 | +end | |
| @@ -0,0 +1,17 @@ | ||
| 1 | +version: "3.8" | |
| 2 | + | |
| 3 | +services: | |
| 4 | + epub-exporter: | |
| 5 | + build: . | |
| 6 | + container_name: obsidian-epub-exporter | |
| 7 | + restart: unless-stopped | |
| 8 | + environment: | |
| 9 | + CREATIVE_FOLDER: "Creative" | |
| 10 | + POLL_INTERVAL: "10" | |
| 11 | + LOG_LEVEL: "INFO" | |
| 12 | + volumes: | |
| 13 | + # Source markdown share. Read-write is needed so export flags can be reset. | |
| 14 | + - /path/to/markdown/share:/vault | |
| 15 | + | |
| 16 | + # Destination folder for generated EPUB files. | |
| 17 | + - /path/to/epub/output:/output | |
| @@ -0,0 +1,64 @@ | ||
| 1 | +/* EPUB stylesheet - Times New Roman body text */ | |
| 2 | + | |
| 3 | +body { | |
| 4 | + font-family: "Times New Roman", "Times", "Georgia", serif; | |
| 5 | + font-size: 1em; | |
| 6 | + line-height: 1.6; | |
| 7 | + margin: 1em; | |
| 8 | + text-align: justify; | |
| 9 | +} | |
| 10 | + | |
| 11 | +h1 { | |
| 12 | + font-family: "Times New Roman", "Times", "Georgia", serif; | |
| 13 | + font-size: 1.8em; | |
| 14 | + font-weight: bold; | |
| 15 | + text-align: center; | |
| 16 | + margin-top: 2em; | |
| 17 | + margin-bottom: 1.5em; | |
| 18 | + page-break-before: always; | |
| 19 | +} | |
| 20 | + | |
| 21 | +h2, h3, h4 { | |
| 22 | + font-family: "Times New Roman", "Times", "Georgia", serif; | |
| 23 | + font-weight: bold; | |
| 24 | +} | |
| 25 | + | |
| 26 | +p { | |
| 27 | + margin-top: 0.3em; | |
| 28 | + margin-bottom: 0.3em; | |
| 29 | + text-indent: 1.5em; | |
| 30 | +} | |
| 31 | + | |
| 32 | +/* First paragraph after a heading or break - no indent */ | |
| 33 | +h1 + p, h2 + p, h3 + p, | |
| 34 | +.center-break + p, | |
| 35 | +div[style*="page-break"] + p { | |
| 36 | + text-indent: 0; | |
| 37 | +} | |
| 38 | + | |
| 39 | +/* Centered section break symbol (~V~) */ | |
| 40 | +p.center-break, | |
| 41 | +p[style*="text-align: center"] { | |
| 42 | + text-indent: 0; | |
| 43 | + text-align: center; | |
| 44 | + margin-top: 1em; | |
| 45 | + margin-bottom: 1em; | |
| 46 | +} | |
| 47 | + | |
| 48 | +/* Emphasis styles */ | |
| 49 | +em { | |
| 50 | + font-style: italic; | |
| 51 | +} | |
| 52 | + | |
| 53 | +strong { | |
| 54 | + font-weight: bold; | |
| 55 | +} | |
| 56 | + | |
| 57 | +/* Block quotes */ | |
| 58 | +blockquote { | |
| 59 | + font-style: italic; | |
| 60 | + margin-left: 2em; | |
| 61 | + margin-right: 2em; | |
| 62 | + border-left: 3px solid #ccc; | |
| 63 | + padding-left: 1em; | |
| 64 | +} | |
| @@ -0,0 +1,269 @@ | ||
| 1 | +""" | |
| 2 | +Story discovery and EPUB 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, and converts | |
| 6 | +to EPUB via pandoc. | |
| 7 | +""" | |
| 8 | + | |
| 9 | +import logging | |
| 10 | +import os | |
| 11 | +import re | |
| 12 | +import subprocess | |
| 13 | +import tempfile | |
| 14 | +from pathlib import Path | |
| 15 | +from typing import Optional | |
| 16 | + | |
| 17 | +import yaml | |
| 18 | + | |
| 19 | +logger = logging.getLogger(__name__) | |
| 20 | + | |
| 21 | +METADATA_FILENAME = "metadata.md" | |
| 22 | +DEFAULT_OUTPUT_FILE_MODE = 0o644 | |
| 23 | + | |
| 24 | + | |
| 25 | +def parse_frontmatter(text: str) -> dict: | |
| 26 | + """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() | |
| 32 | + if not text.startswith("---"): | |
| 33 | + return {} | |
| 34 | + # Find the closing --- | |
| 35 | + end = text.find("---", 3) | |
| 36 | + if end == -1: | |
| 37 | + return {} | |
| 38 | + yaml_block = text[3:end].strip() | |
| 39 | + try: | |
| 40 | + return yaml.safe_load(yaml_block) or {} | |
| 41 | + except yaml.YAMLError as e: | |
| 42 | + logger.error(f"Failed to parse YAML frontmatter: {e}") | |
| 43 | + return {} | |
| 44 | + | |
| 45 | + | |
| 46 | +def write_frontmatter(metadata: dict, original_text: str) -> str: | |
| 47 | + """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 = "" | |
| 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 their chapter number naturally. | |
| 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 | |
| 72 | + # Try to extract chapter number from common patterns | |
| 73 | + match = re.search(r'[Cc]hapter\s+(\d+)', name) | |
| 74 | + if match: | |
| 75 | + return (0, int(match.group(1)), name) | |
| 76 | + # Try plain number at start | |
| 77 | + match = re.match(r'(\d+)', name) | |
| 78 | + if match: | |
| 79 | + return (0, int(match.group(1)), name) | |
| 80 | + # Fallback: alphabetical, but after numbered chapters | |
| 81 | + return (1, 0, name) | |
| 82 | + | |
| 83 | + | |
| 84 | +def discover_stories(vault_path: Path, creative_folder: str = "Creative") -> list[dict]: | |
| 85 | + """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 | |
| 99 | + if not creative_path.exists(): | |
| 100 | + logger.warning(f"Creative folder not found: {creative_path}") | |
| 101 | + return [] | |
| 102 | + | |
| 103 | + stories = [] | |
| 104 | + for metadata_file in creative_path.rglob(METADATA_FILENAME): | |
| 105 | + story_dir = metadata_file.parent | |
| 106 | + try: | |
| 107 | + raw_text = metadata_file.read_text(encoding="utf-8") | |
| 108 | + metadata = parse_frontmatter(raw_text) | |
| 109 | + except Exception as e: | |
| 110 | + logger.error(f"Failed to parse {metadata_file}: {e}") | |
| 111 | + continue | |
| 112 | + | |
| 113 | + if not metadata: | |
| 114 | + logger.warning(f"No valid frontmatter in {metadata_file}, skipping") | |
| 115 | + continue | |
| 116 | + | |
| 117 | + # Collect .md files in the story directory (not subdirectories) | |
| 118 | + chapters = sorted( | |
| 119 | + [f for f in story_dir.iterdir() | |
| 120 | + if f.suffix == ".md" and f.name != METADATA_FILENAME], | |
| 121 | + key=natural_sort_key | |
| 122 | + ) | |
| 123 | + | |
| 124 | + stories.append({ | |
| 125 | + "path": story_dir, | |
| 126 | + "metadata_file": metadata_file, | |
| 127 | + "metadata": metadata, | |
| 128 | + "metadata_raw": raw_text, | |
| 129 | + "chapters": chapters, | |
| 130 | + }) | |
| 131 | + logger.info(f"Discovered story: {metadata.get('title', story_dir.name)} " | |
| 132 | + f"({len(chapters)} chapters)") | |
| 133 | + | |
| 134 | + return stories | |
| 135 | + | |
| 136 | + | |
| 137 | +def merge_chapters(chapters: list[Path], add_page_breaks: bool = True) -> str: | |
| 138 | + """Merge chapter markdown files into a single document. | |
| 139 | + | |
| 140 | + Inserts pagebreak divs between chapters for pandoc to interpret. | |
| 141 | + """ | |
| 142 | + merged_parts = [] | |
| 143 | + | |
| 144 | + for i, chapter_file in enumerate(chapters): | |
| 145 | + try: | |
| 146 | + content = chapter_file.read_text(encoding="utf-8").strip() | |
| 147 | + except Exception as e: | |
| 148 | + logger.error(f"Failed to read {chapter_file}: {e}") | |
| 149 | + continue | |
| 150 | + | |
| 151 | + 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') | |
| 154 | + | |
| 155 | + merged_parts.append(content) | |
| 156 | + | |
| 157 | + return "\n\n".join(merged_parts) | |
| 158 | + | |
| 159 | + | |
| 160 | +def build_epub(story: dict, output_dir: Path, lua_filter: Optional[Path] = None, | |
| 161 | + css_file: Optional[Path] = None) -> Optional[Path]: | |
| 162 | + """Build an EPUB from a story's merged chapters using pandoc. | |
| 163 | + | |
| 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 | + | |
| 170 | + Returns: | |
| 171 | + Path to the generated EPUB, or None on failure. | |
| 172 | + """ | |
| 173 | + metadata = story["metadata"] | |
| 174 | + title = metadata.get("title", story["path"].name) | |
| 175 | + author = metadata.get("author", "Unknown Author") | |
| 176 | + language = metadata.get("language", "en") | |
| 177 | + | |
| 178 | + # Sanitize filename | |
| 179 | + safe_title = re.sub(r'[^\w\s-]', '', title).strip() | |
| 180 | + output_file = output_dir / f"{safe_title}.epub" | |
| 181 | + output_dir.mkdir(parents=True, exist_ok=True) | |
| 182 | + | |
| 183 | + if not story["chapters"]: | |
| 184 | + logger.warning(f"No chapters found for '{title}', skipping") | |
| 185 | + return None | |
| 186 | + | |
| 187 | + logger.info(f"Building EPUB: '{title}' ({len(story['chapters'])} chapters)") | |
| 188 | + | |
| 189 | + # Merge all chapters into a single markdown string | |
| 190 | + merged_content = merge_chapters(story["chapters"]) | |
| 191 | + | |
| 192 | + # Write merged content to a temp file for pandoc | |
| 193 | + with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False, | |
| 194 | + encoding="utf-8") as tmp: | |
| 195 | + tmp.write(merged_content) | |
| 196 | + tmp_path = tmp.name | |
| 197 | + | |
| 198 | + try: | |
| 199 | + # Build pandoc command | |
| 200 | + cmd = [ | |
| 201 | + "pandoc", | |
| 202 | + tmp_path, | |
| 203 | + "-o", str(output_file), | |
| 204 | + "--from", "markdown+raw_html", | |
| 205 | + "--to", "epub3", | |
| 206 | + "--metadata", f"title={title}", | |
| 207 | + "--metadata", f"author={author}", | |
| 208 | + "--metadata", f"lang={language}", | |
| 209 | + "--toc", | |
| 210 | + "--toc-depth=1", | |
| 211 | + "--split-level=1", | |
| 212 | + "--wrap=none", | |
| 213 | + ] | |
| 214 | + | |
| 215 | + # Add cover image: use metadata field if set, otherwise auto-detect | |
| 216 | + cover = metadata.get("cover") | |
| 217 | + cover_path = None | |
| 218 | + if cover: | |
| 219 | + cover_path = story["path"] / cover | |
| 220 | + if not cover_path.exists(): | |
| 221 | + logger.warning(f"Cover image specified but not found: {cover_path}") | |
| 222 | + cover_path = None | |
| 223 | + else: | |
| 224 | + # Auto-detect cover.jpg / cover.png in the story folder | |
| 225 | + for ext in ["jpg", "jpeg", "png"]: | |
| 226 | + candidate = story["path"] / f"cover.{ext}" | |
| 227 | + if candidate.exists(): | |
| 228 | + cover_path = candidate | |
| 229 | + break | |
| 230 | + if cover_path: | |
| 231 | + cmd.extend(["--epub-cover-image", str(cover_path)]) | |
| 232 | + logger.info(f"Using cover image: {cover_path.name}") | |
| 233 | + | |
| 234 | + # Add custom CSS | |
| 235 | + if css_file and css_file.exists(): | |
| 236 | + cmd.extend(["--css", str(css_file)]) | |
| 237 | + | |
| 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 | + ) | |
| 249 | + | |
| 250 | + if result.returncode != 0: | |
| 251 | + logger.error(f"Pandoc failed for '{title}':\n{result.stderr}") | |
| 252 | + return None | |
| 253 | + | |
| 254 | + if result.stderr: | |
| 255 | + logger.warning(f"Pandoc warnings for '{title}':\n{result.stderr}") | |
| 256 | + | |
| 257 | + # Keep generated books readable to any user sharing the mounted output. | |
| 258 | + os.chmod(output_file, DEFAULT_OUTPUT_FILE_MODE) | |
| 259 | + logger.info(f"EPUB created: {output_file}") | |
| 260 | + return output_file | |
| 261 | + | |
| 262 | + except subprocess.TimeoutExpired: | |
| 263 | + logger.error(f"Pandoc timed out for '{title}'") | |
| 264 | + return None | |
| 265 | + except Exception as e: | |
| 266 | + logger.error(f"EPUB build failed for '{title}': {e}") | |
| 267 | + return None | |
| 268 | + finally: | |
| 269 | + Path(tmp_path).unlink(missing_ok=True) | |
| @@ -0,0 +1,132 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +""" | |
| 3 | +Obsidian EPUB Exporter | |
| 4 | + | |
| 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 | |
| 7 | +folder into a single EPUB via pandoc, and writes it to the mounted output folder. | |
| 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 | +""" | |
| 13 | + | |
| 14 | +import logging | |
| 15 | +import os | |
| 16 | +import sys | |
| 17 | +import time | |
| 18 | +from pathlib import Path | |
| 19 | + | |
| 20 | +from epub_builder import build_epub, discover_stories, write_frontmatter | |
| 21 | + | |
| 22 | +# ── Configuration from environment variables ────────────────────────── | |
| 23 | + | |
| 24 | +VAULT_PATH = os.environ.get("VAULT_PATH", "/vault") | |
| 25 | +CREATIVE_FOLDER = os.environ.get("CREATIVE_FOLDER", "Creative") | |
| 26 | +OUTPUT_DIR = os.environ.get("OUTPUT_DIR", "/output") | |
| 27 | + | |
| 28 | +LUA_FILTER_PATH = os.environ.get("LUA_FILTER_PATH", "/app/center-v.lua") | |
| 29 | +CSS_FILE_PATH = os.environ.get("CSS_FILE_PATH", "/app/epub-style.css") | |
| 30 | + | |
| 31 | +# How often to scan for export flags (seconds) | |
| 32 | +POLL_INTERVAL = int(os.environ.get("POLL_INTERVAL", "10")) | |
| 33 | + | |
| 34 | +LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO") | |
| 35 | + | |
| 36 | +# ── Logging setup ───────────────────────────────────────────────────── | |
| 37 | + | |
| 38 | +logging.basicConfig( | |
| 39 | + level=getattr(logging, LOG_LEVEL.upper(), logging.INFO), | |
| 40 | + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", | |
| 41 | + datefmt="%Y-%m-%d %H:%M:%S", | |
| 42 | + handlers=[logging.StreamHandler(sys.stdout)], | |
| 43 | +) | |
| 44 | +logger = logging.getLogger("epub-exporter") | |
| 45 | + | |
| 46 | + | |
| 47 | +def reset_export_flag(story: dict): | |
| 48 | + """Reset the export flag to false in the metadata.md file.""" | |
| 49 | + metadata_file = story["metadata_file"] | |
| 50 | + metadata = story["metadata"] | |
| 51 | + raw_text = story.get("metadata_raw", "") | |
| 52 | + | |
| 53 | + metadata["export"] = False | |
| 54 | + new_content = write_frontmatter(metadata, raw_text) | |
| 55 | + | |
| 56 | + try: | |
| 57 | + with open(metadata_file, "w", encoding="utf-8") as f: | |
| 58 | + f.write(new_content) | |
| 59 | + logger.info(f"Reset export flag: {metadata_file}") | |
| 60 | + except Exception as e: | |
| 61 | + logger.error(f"Failed to reset export flag in {metadata_file}: {e}") | |
| 62 | + | |
| 63 | + | |
| 64 | +def process_exports(vault_path: Path): | |
| 65 | + """Scan for stories with export: true and build EPUBs.""" | |
| 66 | + stories = discover_stories(vault_path, CREATIVE_FOLDER) | |
| 67 | + to_export = [s for s in stories if s["metadata"].get("export") is True] | |
| 68 | + | |
| 69 | + if not to_export: | |
| 70 | + return | |
| 71 | + | |
| 72 | + output_path = Path(OUTPUT_DIR) | |
| 73 | + lua_filter = Path(LUA_FILTER_PATH) if LUA_FILTER_PATH else None | |
| 74 | + css_file = Path(CSS_FILE_PATH) if CSS_FILE_PATH else None | |
| 75 | + | |
| 76 | + for story in to_export: | |
| 77 | + title = story["metadata"].get("title", story["path"].name) | |
| 78 | + logger.info(f"Export requested for: '{title}'") | |
| 79 | + | |
| 80 | + epub_path = build_epub( | |
| 81 | + story=story, | |
| 82 | + output_dir=output_path, | |
| 83 | + lua_filter=lua_filter, | |
| 84 | + css_file=css_file if css_file and css_file.exists() else None, | |
| 85 | + ) | |
| 86 | + | |
| 87 | + if epub_path: | |
| 88 | + logger.info(f"Successfully exported: {epub_path}") | |
| 89 | + reset_export_flag(story) | |
| 90 | + else: | |
| 91 | + logger.error(f"Failed to export '{title}'") | |
| 92 | + | |
| 93 | + | |
| 94 | +def main(): | |
| 95 | + logger.info("=" * 60) | |
| 96 | + logger.info("Obsidian EPUB Exporter starting up") | |
| 97 | + logger.info("=" * 60) | |
| 98 | + logger.info(f"Vault path: {VAULT_PATH}") | |
| 99 | + logger.info(f"Creative folder: {CREATIVE_FOLDER}") | |
| 100 | + logger.info(f"Output dir: {OUTPUT_DIR}") | |
| 101 | + logger.info(f"Poll interval: {POLL_INTERVAL}s") | |
| 102 | + | |
| 103 | + vault_path = Path(VAULT_PATH) | |
| 104 | + Path(OUTPUT_DIR).mkdir(parents=True, exist_ok=True) | |
| 105 | + | |
| 106 | + if not vault_path.exists(): | |
| 107 | + logger.error(f"Vault path does not exist: {vault_path}") | |
| 108 | + logger.info("Waiting for vault to become available...") | |
| 109 | + while not vault_path.exists(): | |
| 110 | + time.sleep(5) | |
| 111 | + logger.info("Vault path is now available") | |
| 112 | + | |
| 113 | + # Check for any pending exports on startup | |
| 114 | + logger.info("Checking for pending exports...") | |
| 115 | + process_exports(vault_path) | |
| 116 | + | |
| 117 | + # Main loop: poll the filesystem | |
| 118 | + logger.info("Entering watch loop...") | |
| 119 | + while True: | |
| 120 | + try: | |
| 121 | + time.sleep(POLL_INTERVAL) | |
| 122 | + process_exports(vault_path) | |
| 123 | + except KeyboardInterrupt: | |
| 124 | + logger.info("Shutting down gracefully...") | |
| 125 | + break | |
| 126 | + except Exception as e: | |
| 127 | + logger.error(f"Error in main loop: {e}") | |
| 128 | + time.sleep(POLL_INTERVAL) | |
| 129 | + | |
| 130 | + | |
| 131 | +if __name__ == "__main__": | |
| 132 | + main() | |
| @@ -0,0 +1 @@ | ||
| 1 | +PyYAML>=6.0 | |