initial commit

15db1f8fa89eb8befa7ea04bbf369d17c66a5919

permissionBRICK <you@example.com>

9 files changed, +674 -0Showing whitespace changes
.gitignore+1 -0
@@ -0,0 +1 @@
1/__pycache__
\ No newline at end of file1 \ No newline at end of file
Dockerfile+22 -0
@@ -0,0 +1,22 @@
1FROM python:3.12-slim
2
3RUN apt-get update && \
4 apt-get install -y --no-install-recommends pandoc && \
5 rm -rf /var/lib/apt/lists/*
6
7RUN mkdir -p /app /output
8
9WORKDIR /app
10
11COPY requirements.txt /app/
12RUN pip install --no-cache-dir -r requirements.txt
13
14COPY main.py epub_builder.py /app/
15COPY center-v.lua /app/
16COPY 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
20VOLUME ["/vault", "/output"]
21
22CMD ["python", "-u", "main.py"]
README.md+81 -0
@@ -0,0 +1,81 @@
1# Obsidian Markdown to EPUB Pipeline
2
3This 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
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.
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`.
104. The finished EPUB is written to `/output` and explicitly set to mode `0644` so it is readable by all users.
115. The container resets `export: false` in the story's `metadata.md`.
12
13## Story layout
14
15Only markdown files directly inside the story folder are treated as chapters. Subfolders are ignored.
16
17```text
18Creative/
19 Novel Name/
20 metadata.md
21 Chapter 1 - Opening.md
22 Chapter 2 - Next Scene.md
23 cover.jpg
24```
25
26Example `metadata.md`:
27
28```md
29---
30title: "Novel Name"
31author: "Your Name"
32language: en
33export: false
34# cover: cover.jpg
35---
36```
37
38## Run with Docker Compose
39
40Update [`docker-compose.yml`](/Users/DevUser/Desktop/SyncNotes/obsidian-epub-pipeline/docker-compose.yml) to point at your real folders, then run:
41
42```bash
43docker compose up -d --build
44```
45
46Watch logs with:
47
48```bash
49docker 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
66Build and test locally first:
67
68```bash
69docker build -t YOUR_DOCKERHUB_USERNAME/obsidian-epub-pipeline:latest .
70docker 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
76Then log in and push:
77
78```bash
79docker login
80docker push YOUR_DOCKERHUB_USERNAME/obsidian-epub-pipeline:latest
81```
center-v.lua+87 -0
@@ -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
10local 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
23end
24
25function 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
87end
docker-compose.yml+17 -0
@@ -0,0 +1,17 @@
1version: "3.8"
2
3services:
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
epub-style.css+64 -0
@@ -0,0 +1,64 @@
1/* EPUB stylesheet - Times New Roman body text */
2
3body {
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
11h1 {
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
21h2, h3, h4 {
22 font-family: "Times New Roman", "Times", "Georgia", serif;
23 font-weight: bold;
24}
25
26p {
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 */
33h1 + p, h2 + p, h3 + p,
34.center-break + p,
35div[style*="page-break"] + p {
36 text-indent: 0;
37}
38
39/* Centered section break symbol (~V~) */
40p.center-break,
41p[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 */
49em {
50 font-style: italic;
51}
52
53strong {
54 font-weight: bold;
55}
56
57/* Block quotes */
58blockquote {
59 font-style: italic;
60 margin-left: 2em;
61 margin-right: 2em;
62 border-left: 3px solid #ccc;
63 padding-left: 1em;
64}
epub_builder.py+269 -0
@@ -0,0 +1,269 @@
1"""
2Story discovery and EPUB build pipeline.
3
4Scans the vault for metadata.md files (with YAML frontmatter), discovers
5chapter files, merges them into a single markdown document, and converts
6to EPUB via pandoc.
7"""
8
9import logging
10import os
11import re
12import subprocess
13import tempfile
14from pathlib import Path
15from typing import Optional
16
17import yaml
18
19logger = logging.getLogger(__name__)
20
21METADATA_FILENAME = "metadata.md"
22DEFAULT_OUTPUT_FILE_MODE = 0o644
23
24
25def 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
46def 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
65def 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
84def 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
137def 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
160def 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)
main.py+132 -0
@@ -0,0 +1,132 @@
1#!/usr/bin/env python3
2"""
3Obsidian EPUB Exporter
4
5Watches a mounted Obsidian vault for metadata.md files with `export: true`
6in their YAML frontmatter. When found, merges all chapter .md files in that
7folder into a single EPUB via pandoc, and writes it to the mounted output folder.
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"""
13
14import logging
15import os
16import sys
17import time
18from pathlib import Path
19
20from epub_builder import build_epub, discover_stories, write_frontmatter
21
22# ── Configuration from environment variables ──────────────────────────
23
24VAULT_PATH = os.environ.get("VAULT_PATH", "/vault")
25CREATIVE_FOLDER = os.environ.get("CREATIVE_FOLDER", "Creative")
26OUTPUT_DIR = os.environ.get("OUTPUT_DIR", "/output")
27
28LUA_FILTER_PATH = os.environ.get("LUA_FILTER_PATH", "/app/center-v.lua")
29CSS_FILE_PATH = os.environ.get("CSS_FILE_PATH", "/app/epub-style.css")
30
31# How often to scan for export flags (seconds)
32POLL_INTERVAL = int(os.environ.get("POLL_INTERVAL", "10"))
33
34LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO")
35
36# ── Logging setup ─────────────────────────────────────────────────────
37
38logging.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)
44logger = logging.getLogger("epub-exporter")
45
46
47def 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
64def 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
94def 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
131if __name__ == "__main__":
132 main()
requirements.txt+1 -0
@@ -0,0 +1 @@
1PyYAML>=6.0