commit b80cd14af25a871b406eedb4ecc729fd4a451ae7 Author: randogoth Date: Tue Feb 10 20:46:54 2026 +0200 init diff --git a/README.md b/README.md new file mode 100644 index 0000000..80a0e0a --- /dev/null +++ b/README.md @@ -0,0 +1,39 @@ +# monkeysucker + +This tool converts [WriteMonkey 3](https://github.com/writemonkey/wm3/) sheet files into markdown files. + +## Where the database files live + +WriteMonkey 3 stores its database files in the local application data directory: + +- Windows: `c:\Users\[user]\AppData\Local\Writemonkey 3\writemonkey3_sheets` +- macOS: `/Users/[user]/Library/Application Support/Writemonkey 3/writemonkey3_sheets` +- Linux: `/var/local/Writemonkey 3/writemonkey3_sheets` + +Additional files in the same folder: + +- `writemonkey3_sheets_repository` (snippets/repository) +- `writemonkey3_sheets_history` (history/restore points) +- `writemonkey3_sheets_projects` (projects) +- `writemonkey3_custom_dictionary` (plain text) + +You can also open these folders from within WriteMonkey 3 via its command palette. + +## Usage + +``` +uv run --from git+https://codeberg.org/randogoth/monkeysucker ./monkeysucker.py path/to/datadir path/to/outputdir +``` + +## Output rules + +- One markdown file per document, named `yyyymmdd_sanitized_title.md` (UTC creation date). +- Only the latest record per document id is exported. +- YAML frontmatter includes `id`, `title`, `created`, and `modified` (UTC dates). Tags are included only when present. +- If a document has snippets, a companion file is created with `_snippets` before the extension. +- Documents are placed into project folders; otherwise they go to the output root. +- File mtime is set to the document's modified time. + +## Notes + +- The output directory is deleted and recreated on each run. \ No newline at end of file diff --git a/monkeysucker.py b/monkeysucker.py new file mode 100755 index 0000000..69444d6 --- /dev/null +++ b/monkeysucker.py @@ -0,0 +1,230 @@ +#!/usr/bin/env python3 +"""Convert WriteMonkey 3 sheets to markdown files with snippets.""" +from __future__ import annotations + +import argparse +import json +import os +import shutil +import re +from collections import defaultdict +from datetime import datetime, timezone +from pathlib import Path +from typing import Dict, Iterable, List, Tuple + + +def _read_records(path: Path) -> Iterable[Tuple[dict, dict]]: + with path.open("r", encoding="utf-8", errors="replace") as f: + while True: + header = f.readline() + if not header: + break + meta = f.readline() + data = f.readline() + if not meta or not data: + break + yield json.loads(meta), json.loads(data) + + +def _slugify(text: str, max_len: int = 80) -> str: + text = text.strip().lower() + text = re.sub(r"[^a-z0-9\- _]+", "", text) + text = re.sub(r"[\s_]+", "_", text) + text = re.sub(r"-+", "-", text) + text = text.strip("_-") + return text[:max_len] or "untitled" + + +def _date_str_utc(ms: int | float | None) -> str: + if not ms: + return "1970-01-01" + dt = datetime.fromtimestamp(ms / 1000, tz=timezone.utc) + return dt.strftime("%Y-%m-%d") + + +def _filename_date_utc(ms: int | float | None) -> str: + if not ms: + return "19700101" + dt = datetime.fromtimestamp(ms / 1000, tz=timezone.utc) + return dt.strftime("%Y%m%d") + + +def _yaml_escape(text: str) -> str: + text = text.replace("\\", "\\\\").replace('"', '\\"') + return f'"{text}"' + + +def _write_text(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8", errors="replace") as f: + f.write(content) + + +def _set_mtime(path: Path, ms: int | float | None) -> None: + if not ms: + return + ts = ms / 1000 + os.utime(path, (ts, ts)) + + +def _frontmatter(doc_id: int, title: str, created: str, modified: str, tags: List[str]) -> str: + lines = [ + "---", + f"id: {doc_id}", + f"title: {_yaml_escape(title)}", + f"created: {created}", + f"modified: {modified}", + ] + if tags: + lines.append("tags:") + for tag in tags: + lines.append(f" - {_yaml_escape(str(tag))}") + lines.append("---") + return "\n".join(lines) + "\n\n" + + +def _next_available_name(folder: Path, base: str, ext: str, used: Dict[Path, set]) -> str: + used_names = used.setdefault(folder, set()) + candidate = f"{base}{ext}" + if candidate not in used_names and not (folder / candidate).exists(): + used_names.add(candidate) + return candidate + index = 1 + while True: + candidate = f"{base}_{index}{ext}" + if candidate not in used_names and not (folder / candidate).exists(): + used_names.add(candidate) + return candidate + index += 1 + + +def _pid_to_int(pid) -> int | None: + if pid is None: + return None + if isinstance(pid, int): + return pid + if isinstance(pid, str) and pid.isdigit(): + return int(pid) + return None + + +def _load_projects(input_dir: Path) -> Dict[int, str]: + projects_path = input_dir / "writemonkey3_sheets_projects" + if not projects_path.exists(): + return {} + projects: Dict[int, str] = {} + for meta, data in _read_records(projects_path): + proj_id = meta.get("_id") + name = data.get("nam", "") + if isinstance(proj_id, int): + projects[proj_id] = name + return projects + + +def _load_snippets(input_dir: Path) -> Dict[int, List[Tuple[int, str]]]: + repo_path = input_dir / "writemonkey3_sheets_repository" + if not repo_path.exists(): + return {} + snippets: Dict[int, List[Tuple[int, str]]] = defaultdict(list) + for _, data in _read_records(repo_path): + if data.get("typ") != "snippet": + continue + did = data.get("did") + if not isinstance(did, int): + continue + order = data.get("ord") + if not isinstance(order, int): + order = 0 + txt = data.get("txt", "") + snippets[did].append((order, txt)) + for did in list(snippets.keys()): + snippets[did].sort(key=lambda item: item[0]) + return snippets + + +def _latest_docs(path: Path) -> List[Tuple[dict, dict]]: + latest: Dict[int, Tuple[int, dict, dict]] = {} + for meta, data in _read_records(path): + doc_id = meta.get("_id") + if not isinstance(doc_id, int): + continue + dtm = data.get("dtm") + dtc = data.get("dtc") + stamp = dtm if isinstance(dtm, (int, float)) else dtc if isinstance(dtc, (int, float)) else 0 + current = latest.get(doc_id) + if current is None or stamp > current[0]: + latest[doc_id] = (int(stamp), meta, data) + return [(meta, data) for _, meta, data in latest.values()] + + +def export_documents(input_dir: Path, output_dir: Path) -> None: + sheets_path = input_dir / "writemonkey3_sheets" + if not sheets_path.exists(): + raise SystemExit(f"Missing file: {sheets_path}") + + projects = _load_projects(input_dir) + snippets = _load_snippets(input_dir) + used_names: Dict[Path, set] = {} + + for meta, data in _latest_docs(sheets_path): + doc_id = meta.get("_id") + if not isinstance(doc_id, int): + continue + + title = data.get("nam", "") + dtc = data.get("dtc") + dtm = data.get("dtm") + created_date = _date_str_utc(dtc) + modified_date = _date_str_utc(dtm or dtc) + filename_date = _filename_date_utc(dtc) + slug = _slugify(title) + base = f"{filename_date}_{slug}" + + pid = _pid_to_int(data.get("pid")) + if pid is not None and pid in projects: + project_folder = _slugify(projects[pid]) + folder = output_dir / project_folder + else: + folder = output_dir + + filename = _next_available_name(folder, base, ".md", used_names) + path = folder / filename + + tags = [] + met = data.get("met") + if isinstance(met, dict): + raw_tags = met.get("tags") + if isinstance(raw_tags, list) and raw_tags: + tags = [str(tag) for tag in raw_tags] + + frontmatter = _frontmatter(doc_id, title, created_date, modified_date, tags) + body = data.get("txt", "") + _write_text(path, frontmatter + body) + _set_mtime(path, dtm or dtc) + + doc_snippets = snippets.get(doc_id) + if doc_snippets: + snippet_name = path.with_name(path.stem + "_snippets" + path.suffix) + snippets_text = "\n---\n".join(text for _, text in doc_snippets) + _write_text(snippet_name, frontmatter + snippets_text) + _set_mtime(snippet_name, dtm or dtc) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Convert WriteMonkey 3 sheets to markdown files.") + parser.add_argument("input_dir", help="Directory containing WriteMonkey sheets") + parser.add_argument("output_dir", help="Output directory") + args = parser.parse_args() + + input_dir = Path(args.input_dir) + output_dir = Path(args.output_dir) + if output_dir.exists(): + shutil.rmtree(output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + + export_documents(input_dir, output_dir) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())