init
This commit is contained in:
commit
b80cd14af2
2 changed files with 269 additions and 0 deletions
230
monkeysucker.py
Executable file
230
monkeysucker.py
Executable file
|
|
@ -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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue