initial commit

15db1f8fa89eb8befa7ea04bbf369d17c66a5919

permissionBRICK <you@example.com>

9 files changed, +674 -0Showing whitespace changes
.gitignore+1 -0
@@ -0,0 +1 @@
1+/__pycache__
1 \ No newline at end of file
Dockerfile+22 -0
@@ -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"]
README.md+81 -0
@@ -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+```
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+
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
docker-compose.yml+17 -0
@@ -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
epub-style.css+64 -0
@@ -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+}
epub_builder.py+269 -0
@@ -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)
main.py+132 -0
@@ -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()
requirements.txt+1 -0
@@ -0,0 +1 @@
1+PyYAML>=6.0