| 1 | #!/usr/bin/env python3 |
| 2 | """ |
| 3 | Obsidian ebook exporter. |
| 4 | |
| 5 | Watches a mounted Obsidian vault for metadata.md files with `export: true` |
| 6 | in their YAML frontmatter. When found, it builds DOCX, EPUB, and MOBI files |
| 7 | in the mounted output folder, then resets the flag back to false. |
| 8 | """ |
| 9 | |
| 10 | import logging |
| 11 | import os |
| 12 | import sys |
| 13 | import time |
| 14 | from pathlib import Path |
| 15 | |
| 16 | from epub_builder import build_outputs, discover_stories, set_output_ownership, write_frontmatter |
| 17 | |
| 18 | # ── Configuration from environment variables ────────────────────────── |
| 19 | |
| 20 | VAULT_PATH = os.environ.get("VAULT_PATH", "/vault") |
| 21 | CREATIVE_FOLDER = os.environ.get("CREATIVE_FOLDER", "Creative") |
| 22 | OUTPUT_DIR = os.environ.get("OUTPUT_DIR", "/output") |
| 23 | |
| 24 | LUA_FILTER_PATH = os.environ.get("LUA_FILTER_PATH", "/app/center-v.lua") |
| 25 | PAGEBREAK_FILTER_PATH = os.environ.get("PAGEBREAK_FILTER_PATH", "/app/pagebreak.lua") |
| 26 | CSS_FILE_PATH = os.environ.get("CSS_FILE_PATH", "/app/epub-style.css") |
| 27 | |
| 28 | # How often to scan for export flags (seconds) |
| 29 | POLL_INTERVAL = int(os.environ.get("POLL_INTERVAL", "10")) |
| 30 | |
| 31 | LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO") |
| 32 | |
| 33 | # ── Logging setup ───────────────────────────────────────────────────── |
| 34 | |
| 35 | logging.basicConfig( |
| 36 | level=getattr(logging, LOG_LEVEL.upper(), logging.INFO), |
| 37 | format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", |
| 38 | datefmt="%Y-%m-%d %H:%M:%S", |
| 39 | handlers=[logging.StreamHandler(sys.stdout)], |
| 40 | ) |
| 41 | logger = logging.getLogger("epub-exporter") |
| 42 | |
| 43 | |
| 44 | def reset_export_flag(story: dict): |
| 45 | """Reset the export flag to false in the metadata.md file.""" |
| 46 | metadata_file = story["metadata_file"] |
| 47 | metadata = story["metadata"] |
| 48 | raw_text = story.get("metadata_raw", "") |
| 49 | |
| 50 | metadata["export"] = False |
| 51 | new_content = write_frontmatter(metadata, raw_text) |
| 52 | |
| 53 | try: |
| 54 | with open(metadata_file, "w", encoding="utf-8") as f: |
| 55 | f.write(new_content) |
| 56 | logger.info(f"Reset export flag: {metadata_file}") |
| 57 | except Exception as e: |
| 58 | logger.error(f"Failed to reset export flag in {metadata_file}: {e}") |
| 59 | |
| 60 | |
| 61 | def process_exports(vault_path: Path): |
| 62 | """Scan for stories with export: true and build ebook outputs.""" |
| 63 | stories = discover_stories(vault_path, CREATIVE_FOLDER) |
| 64 | to_export = [s for s in stories if s["metadata"].get("export") is True] |
| 65 | |
| 66 | if not to_export: |
| 67 | return |
| 68 | |
| 69 | output_path = Path(OUTPUT_DIR) |
| 70 | lua_filter = Path(LUA_FILTER_PATH) if LUA_FILTER_PATH else None |
| 71 | pagebreak_filter = Path(PAGEBREAK_FILTER_PATH) if PAGEBREAK_FILTER_PATH else None |
| 72 | css_file = Path(CSS_FILE_PATH) if CSS_FILE_PATH else None |
| 73 | |
| 74 | for story in to_export: |
| 75 | title = story["metadata"].get("title", story["path"].name) |
| 76 | logger.info(f"Export requested for: '{title}'") |
| 77 | |
| 78 | built_files = build_outputs( |
| 79 | story=story, |
| 80 | output_dir=output_path, |
| 81 | lua_filter=lua_filter, |
| 82 | pagebreak_filter=pagebreak_filter, |
| 83 | css_file=css_file if css_file and css_file.exists() else None, |
| 84 | ) |
| 85 | |
| 86 | if built_files: |
| 87 | created = ", ".join(str(path.name) for path in built_files.values()) |
| 88 | logger.info(f"Successfully exported: {created}") |
| 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 ebook 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 | output_path = Path(OUTPUT_DIR) |
| 105 | output_path.mkdir(parents=True, exist_ok=True) |
| 106 | set_output_ownership(output_path) |
| 107 | |
| 108 | if not vault_path.exists(): |
| 109 | logger.error(f"Vault path does not exist: {vault_path}") |
| 110 | logger.info("Waiting for vault to become available...") |
| 111 | while not vault_path.exists(): |
| 112 | time.sleep(5) |
| 113 | logger.info("Vault path is now available") |
| 114 | |
| 115 | # Check for any pending exports on startup |
| 116 | logger.info("Checking for pending exports...") |
| 117 | process_exports(vault_path) |
| 118 | |
| 119 | # Main loop: poll the filesystem |
| 120 | logger.info("Entering watch loop...") |
| 121 | while True: |
| 122 | try: |
| 123 | time.sleep(POLL_INTERVAL) |
| 124 | process_exports(vault_path) |
| 125 | except KeyboardInterrupt: |
| 126 | logger.info("Shutting down gracefully...") |
| 127 | break |
| 128 | except Exception as e: |
| 129 | logger.error(f"Error in main loop: {e}") |
| 130 | time.sleep(POLL_INTERVAL) |
| 131 | |
| 132 | |
| 133 | if __name__ == "__main__": |
| 134 | main() |