230 lines
7.2 KiB
Python
Executable file
230 lines
7.2 KiB
Python
Executable file
#!/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())
|