initial commit
| @@ -0,0 +1 @@ | |||
| 1 | /__pycache__ | ||
| \ No newline at end of file | 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 | ||