v2 - docx and convert
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | FROM python:3.12-slim |
| 2 | 2 | |
| 3 | 3 | RUN apt-get update && \ |
| 4 | 4 | apt-get install -y --no-install-recommends pandoc calibre && \ |
| 5 | 5 | rm -rf /var/lib/apt/lists/* |
| 6 | 6 | |
| 7 | 7 | RUN mkdir -p /app /output |
| @@ -1,14 +1,15 @@ | ||
| 1 | 1 | # Obsidian Markdown to EPUBEbook Pipeline |
| 2 | 2 | |
| 3 | 3 | This container watches a mounted markdown share for story folders and generates DOCX, EPUB, and MOBI files into a separate mounted output folder. |
| 4 | 4 | |
| 5 | 5 | ## How it works |
| 6 | 6 | |
| 7 | 7 | 1. The container scans the mounted vault under `/vault/<CREATIVE_FOLDER>`. |
| 8 | 8 | 2. Each story folder is identified by a `metadata.md` file with YAML frontmatter. |
| 9 | 9 | 3. 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`. | |
| 12 | 13 | |
| 13 | 14 | ## Story layout |
| 14 | 15 | |
| @@ -59,7 +60,7 @@ docker compose logs -f | ||
| 59 | 60 | | `VAULT_PATH` | `/vault` | Mounted source folder | |
| 60 | 61 | | `OUTPUT_DIR` | `/output` | Mounted output folder | |
| 61 | 62 | | `LUA_FILTER_PATH` | `/app/center-v.lua` | Pandoc Lua filter | |
| 62 | 63 | | `CSS_FILE_PATH` | `/app/epub-style.css` | EPUBExtra stylesheet used during EPUB and MOBI conversion | |
| 63 | 64 | |
| 64 | 65 | ## Docker Hub publishing flow |
| 65 | 66 | |
| @@ -1,9 +1,9 @@ | ||
| 1 | 1 | """ |
| 2 | 2 | Story discovery and EPUBebook build pipeline. |
| 3 | 3 | |
| 4 | 4 | Scans the vault for metadata.md files (with YAML frontmatter), discovers |
| 5 | 5 | chapter 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. | |
| 7 | 7 | """ |
| 8 | 8 | |
| 9 | 9 | import logging |
| @@ -20,21 +20,20 @@ logger = logging.getLogger(__name__) | ||
| 20 | 20 | |
| 21 | 21 | METADATA_FILENAME = "metadata.md" |
| 22 | 22 | DEFAULT_OUTPUT_FILE_MODE = 0o644 |
| 23 | +EPUB_SUBDIR = "epub" | |
| 24 | +MOBI_SUBDIR = "mobi" | |
| 23 | 25 | |
| 24 | 26 | |
| 25 | 27 | def parse_frontmatter(text: str) -> dict: |
| 26 | 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 | 29 | text = text.strip() |
| 32 | 30 | if not text.startswith("---"): |
| 33 | 31 | return {} |
| 34 | - # Find the closing --- | |
| 32 | + | |
| 35 | 33 | end = text.find("---", 3) |
| 36 | 34 | if end == -1: |
| 37 | 35 | return {} |
| 36 | + | |
| 38 | 37 | yaml_block = text[3:end].strip() |
| 39 | 38 | try: |
| 40 | 39 | return yaml.safe_load(yaml_block) or {} |
| @@ -44,10 +43,7 @@ def parse_frontmatter(text: str) -> dict: | ||
| 44 | 43 | |
| 45 | 44 | |
| 46 | 45 | def write_frontmatter(metadata: dict, original_text: str) -> str: |
| 47 | 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 | 47 | body = "" |
| 52 | 48 | original_text = original_text.strip() |
| 53 | 49 | if original_text.startswith("---"): |
| @@ -63,38 +59,22 @@ def write_frontmatter(metadata: dict, original_text: str) -> str: | ||
| 63 | 59 | |
| 64 | 60 | |
| 65 | 61 | def natural_sort_key(path: Path) -> tuple: |
| 66 | 62 | """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 | - """ | |
| 71 | 63 | name = path.stem |
| 72 | - # Try to extract chapter number from common patterns | |
| 64 | + | |
| 73 | 65 | match = re.search(r'"[Cc]hapter\s+(\d+)'", name) |
| 74 | 66 | if match: |
| 75 | 67 | return (0, int(match.group(1)), name) |
| 76 | - # Try plain number at start | |
| 68 | + | |
| 77 | 69 | match = re.match(r'"(\d+)'", name) |
| 78 | 70 | if match: |
| 79 | 71 | return (0, int(match.group(1)), name) |
| 80 | - # Fallback: alphabetical, but after numbered chapters | |
| 72 | + | |
| 81 | 73 | return (1, 0, name) |
| 82 | 74 | |
| 83 | 75 | |
| 84 | 76 | def discover_stories(vault_path: Path, creative_folder: str = "Creative") -> list[dict]: |
| 85 | 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 | 78 | creative_path = vault_path / creative_folder |
| 99 | 79 | if not creative_path.exists(): |
| 100 | 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 | 94 | logger.warning(f"No valid frontmatter in {metadata_file}, skipping") |
| 115 | 95 | continue |
| 116 | 96 | |
| 117 | - # Collect .md files in the story directory (not subdirectories) | |
| 118 | 97 | chapters = sorted( |
| 119 | 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 | ) |
| 123 | 101 | |
| 124 | 102 | stories.append({ |
| 103 | + { | |
| 125 | 104 | "path": story_dir, |
| 126 | 105 | "metadata_file": metadata_file, |
| 127 | 106 | "metadata": metadata, |
| 128 | 107 | "metadata_raw": raw_text, |
| 129 | 108 | "chapters": chapters, |
| 130 | - }) | |
| 109 | + } | |
| 131 | - logger.info(f"Discovered story: {metadata.get('title', story_dir.name)} " | |
| 110 | + ) | |
| 132 | - f"({len(chapters)} chapters)") | |
| 111 | + logger.info( | |
| 112 | + f"Discovered story: {metadata.get('title', story_dir.name)} " | |
| 113 | + f"({len(chapters)} chapters)" | |
| 114 | + ) | |
| 133 | 115 | |
| 134 | 116 | return stories |
| 135 | 117 | |
| 136 | 118 | |
| 137 | 119 | def merge_chapters(chapters: list[Path], add_page_breaks: bool = True) -> str: |
| 138 | 120 | """Merge chapter markdown files into a single document.""" |
| 139 | - | |
| 140 | - Inserts pagebreak divs between chapters for pandoc to interpret. | |
| 141 | - """ | |
| 142 | 121 | merged_parts = [] |
| 143 | 122 | |
| 144 | 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 | 128 | continue |
| 150 | 129 | |
| 151 | 130 | if i > 0 and add_page_breaks: |
| 152 | - # Pandoc-compatible page break (works for EPUB and DOCX) | |
| 153 | 131 | merged_parts.append('\n\n<div style="page-break-before: always;"></div>\n\n') |
| 154 | 132 | |
| 155 | 133 | merged_parts.append(content) |
| @@ -157,113 +135,166 @@ def merge_chapters(chapters: list[Path], add_page_breaks: bool = True) -> str: | ||
| 157 | 135 | return "\n\n".join(merged_parts) |
| 158 | 136 | |
| 159 | 137 | |
| 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() | |
| 163 | 141 | |
| 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 | 142 | |
| 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.""" | |
| 173 | 197 | metadata = story["metadata"] |
| 174 | 198 | title = metadata.get("title", story["path"].name) |
| 175 | 199 | author = metadata.get("author", "Unknown Author") |
| 176 | 200 | language = metadata.get("language", "en") |
| 177 | 201 | |
| 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 | |
| 180 | 204 | 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" | |
| 181 | 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) | |
| 182 | 211 | |
| 183 | 212 | if not story["chapters"]: |
| 184 | 213 | logger.warning(f"No chapters found for '{title}', skipping") |
| 185 | 214 | return None |
| 186 | 215 | |
| 187 | 216 | logger.info(f"Building EPUBbook files: '{title}' ({len(story['chapters'])} chapters)") |
| 188 | 217 | |
| 189 | - # Merge all chapters into a single markdown string | |
| 190 | 218 | merged_content = merge_chapters(story["chapters"]) |
| 219 | + cover_path = get_cover_path(story) | |
| 191 | 220 | |
| 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: | |
| 195 | 222 | tmp.write(merged_content) |
| 196 | 223 | tmp_path = tmp.name |
| 197 | 224 | |
| 198 | 225 | try: |
| 199 | - # Build pandoc command | |
| 226 | + docx_cmd = [ | |
| 200 | - cmd = [ | |
| 201 | 227 | "pandoc", |
| 202 | 228 | tmp_path, |
| 203 | 229 | "-o", str(output_file), |
| 204 | - "--from", "markdown+raw_html", | |
| 230 | + str(docx_file), | |
| 205 | 231 | "--to", "epub3from", |
| 206 | - "--metadata", f"title={title}", | |
| 232 | + "markdown+raw_html", | |
| 207 | 233 | "--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}", | |
| 209 | 241 | "--toc", |
| 210 | 242 | "--toc-depth=1", |
| 211 | - "--split-level=1", | |
| 212 | 243 | "--wrap=none", |
| 213 | 244 | ] |
| 214 | 245 | |
| 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 | + | |
| 222 | 253 | 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 | + | |
| 230 | 273 | if cover_path: |
| 231 | - cmd.extend(["--epub-cover-image", str(cover_path)]) | |
| 232 | 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)]) | |
| 233 | 277 | |
| 234 | - # Add custom CSS | |
| 235 | 278 | if css_file and css_file.exists(): |
| 236 | 279 | 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 | 281 | |
| 242 | - logger.debug(f"Running: {' '.join(cmd)}") | |
| 282 | + if not run_command(epub_cmd, title, "EPUB"): | |
| 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 | 283 | return None |
| 284 | + logger.info(f"EPUB created: {epub_file}") | |
| 253 | 285 | |
| 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}") | |
| 256 | 289 | |
| 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 | |
| 261 | 292 | |
| 262 | 293 | except subprocess.TimeoutExpired: |
| 263 | 294 | logger.error(f"PandocBuild timed out for '{title}'") |
| 264 | - return None | |
| 265 | 295 | except Exception as e: |
| 266 | 296 | logger.error(f"EPUB buildBuild failed for '{title}': {e}") |
| 267 | - return None | |
| 268 | 297 | finally: |
| 269 | 298 | Path(tmp_path).unlink(missing_ok=True) |
| 299 | + | |
| 300 | + return None | |
| @@ -1,14 +1,10 @@ | ||
| 1 | 1 | #!/usr/bin/env python3 |
| 2 | 2 | """ |
| 3 | 3 | Obsidian EPUBebook Exporterexporter. |
| 4 | 4 | |
| 5 | 5 | Watches a mounted Obsidian vault for metadata.md files with `export: true` |
| 6 | 6 | in 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. | |
| 12 | 8 | """ |
| 13 | 9 | |
| 14 | 10 | import logging |
| @@ -17,7 +13,7 @@ import sys | ||
| 17 | 13 | import time |
| 18 | 14 | from pathlib import Path |
| 19 | 15 | |
| 20 | 16 | from epub_builder import build_epubbuild_outputs, discover_stories, write_frontmatter |
| 21 | 17 | |
| 22 | 18 | # ── Configuration from environment variables ────────────────────────── |
| 23 | 19 | |
| @@ -62,7 +58,7 @@ def reset_export_flag(story: dict): | ||
| 62 | 58 | |
| 63 | 59 | |
| 64 | 60 | def process_exports(vault_path: Path): |
| 65 | 61 | """Scan for stories with export: true and build EPUBsebook outputs.""" |
| 66 | 62 | stories = discover_stories(vault_path, CREATIVE_FOLDER) |
| 67 | 63 | to_export = [s for s in stories if s["metadata"].get("export") is True] |
| 68 | 64 | |
| @@ -77,15 +73,16 @@ def process_exports(vault_path: Path): | ||
| 77 | 73 | title = story["metadata"].get("title", story["path"].name) |
| 78 | 74 | logger.info(f"Export requested for: '{title}'") |
| 79 | 75 | |
| 80 | 76 | epub_pathbuilt_files = build_epubbuild_outputs( |
| 81 | 77 | story=story, |
| 82 | 78 | output_dir=output_path, |
| 83 | 79 | lua_filter=lua_filter, |
| 84 | 80 | css_file=css_file if css_file and css_file.exists() else None, |
| 85 | 81 | ) |
| 86 | 82 | |
| 87 | 83 | 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}") | |
| 89 | 86 | reset_export_flag(story) |
| 90 | 87 | else: |
| 91 | 88 | logger.error(f"Failed to export '{title}'") |
| @@ -93,7 +90,7 @@ def process_exports(vault_path: Path): | ||
| 93 | 90 | |
| 94 | 91 | def main(): |
| 95 | 92 | logger.info("=" * 60) |
| 96 | 93 | logger.info("Obsidian EPUBebook Exporterexporter starting up") |
| 97 | 94 | logger.info("=" * 60) |
| 98 | 95 | logger.info(f"Vault path: {VAULT_PATH}") |
| 99 | 96 | logger.info(f"Creative folder: {CREATIVE_FOLDER}") |