592 lines
16 KiB
Python
592 lines
16 KiB
Python
"""Emit Threadwell archive notes from a thread_data.json dump. No fetch, no lift."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import shutil
|
|
import sys
|
|
from dataclasses import dataclass
|
|
from datetime import date
|
|
from pathlib import Path
|
|
from urllib.parse import parse_qs, unquote, urlparse
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
|
|
from twitter.models import ThreadData, load_thread
|
|
from twitter.render import render_branch, render_spine
|
|
from twitter.slug import branch_file_name, date_prefix, thread_dir_name
|
|
from twitter.tree import (
|
|
by_id,
|
|
branch_roots,
|
|
children_map,
|
|
descendants,
|
|
spine_from_tip,
|
|
spine_ids,
|
|
)
|
|
|
|
_POST_ID_LINE = re.compile(r'^post_id:\s*"?([^"\s]+)"?\s*$')
|
|
_WIKILINK_ITEM = re.compile(r"^- \[\[([^\]|]+)\]\]\s*$")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class EmitResult:
|
|
post_id: str
|
|
handle: str
|
|
dir_name: str
|
|
branch_count: int
|
|
|
|
|
|
def _url_media_id(url: str, index: int) -> str:
|
|
segment = Path(unquote(urlparse(url).path)).name
|
|
return segment if segment else f"m{index}"
|
|
|
|
|
|
def _url_ext(url: str) -> str | None:
|
|
parsed = urlparse(url)
|
|
fmt = parse_qs(parsed.query).get("format", [None])[0]
|
|
if fmt:
|
|
return fmt.lstrip(".").lower()
|
|
suffix = Path(unquote(parsed.path)).suffix
|
|
if suffix:
|
|
return suffix.lstrip(".").lower()
|
|
return None
|
|
|
|
|
|
def _media_files(media_dir: Path) -> list[Path]:
|
|
if not media_dir.is_dir():
|
|
return []
|
|
return sorted(p for p in media_dir.iterdir() if p.is_file())
|
|
|
|
|
|
def _take_preferred(unused: list[Path], post_id: str, index: int) -> Path | None:
|
|
want = f"{post_id}_{index}"
|
|
for path in unused:
|
|
if path.stem == want:
|
|
return path
|
|
return None
|
|
|
|
|
|
def _take_fallback(unused: list[Path], post_id: str) -> Path | None:
|
|
prefix = f"{post_id}_"
|
|
for path in unused:
|
|
if path.name.startswith(prefix):
|
|
return path
|
|
return None
|
|
|
|
|
|
def collect_media(
|
|
thread: ThreadData,
|
|
input_dir: Path,
|
|
dest_dir: Path,
|
|
) -> tuple[list[dict[str, object]], dict[str, tuple[str, ...]]]:
|
|
unused = _media_files(input_dir / "media")
|
|
items: list[dict[str, object]] = []
|
|
by_post: dict[str, list[str]] = {}
|
|
|
|
for post in thread.posts:
|
|
for index, url in enumerate(post.media_urls, start=1):
|
|
media_id = _url_media_id(url, index)
|
|
src = _take_preferred(unused, post.post_id, index)
|
|
if src is None:
|
|
src = _take_fallback(unused, post.post_id)
|
|
if src is not None:
|
|
unused.remove(src)
|
|
ext = src.suffix.lstrip(".") or _url_ext(url) or "bin"
|
|
else:
|
|
ext = _url_ext(url) or "bin"
|
|
filename = f"{post.post_id}_{media_id}_orig.{ext}"
|
|
if src is not None:
|
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(src, dest_dir / filename)
|
|
items.append({
|
|
"post_id": post.post_id,
|
|
"media_id": media_id,
|
|
"handle": post.handle,
|
|
"role": "orig",
|
|
"filename": filename,
|
|
"publish": False,
|
|
"url": None,
|
|
})
|
|
by_post.setdefault(post.post_id, []).append(filename)
|
|
|
|
media_by_post = {pid: tuple(names) for pid, names in by_post.items()}
|
|
return items, media_by_post
|
|
|
|
|
|
def missing_parent_ids(thread: ThreadData) -> list[str]:
|
|
ids = by_id(thread)
|
|
found: list[str] = []
|
|
seen: set[str] = set()
|
|
for post in thread.posts:
|
|
parent = post.reply_to_id
|
|
if parent is None or parent in ids or parent in seen:
|
|
continue
|
|
seen.add(parent)
|
|
found.append(parent)
|
|
return found
|
|
|
|
|
|
def empty_text_ids(thread: ThreadData) -> list[str]:
|
|
return [p.post_id for p in thread.posts if not (p.text or "").strip()]
|
|
|
|
|
|
def render_gaps(thread: ThreadData, spine: list[str], *, input_kind: str = "root") -> str:
|
|
ids = by_id(thread)
|
|
suggested = spine[-1] if spine else ""
|
|
quote_ids = [p.post_id for p in thread.posts if p.quote_of_id]
|
|
missing = [
|
|
p.post_id
|
|
for p in thread.posts
|
|
if p.reply_to_id is not None and p.reply_to_id not in ids
|
|
]
|
|
empty = empty_text_ids(thread)
|
|
|
|
lines = [
|
|
"# gaps",
|
|
f"input: {input_kind}",
|
|
f"suggested_tip: {suggested}",
|
|
]
|
|
if quote_ids:
|
|
lines.append("quote_of:")
|
|
lines.extend(quote_ids)
|
|
else:
|
|
lines.append("quote_of: unset")
|
|
lines.append("missing_reply_to:")
|
|
lines.extend(missing)
|
|
lines.append("empty_text:")
|
|
lines.extend(empty)
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
def _frontmatter_block(text: str) -> str:
|
|
if not text.startswith("---"):
|
|
return ""
|
|
rest = text[3:]
|
|
if rest.startswith("\n"):
|
|
rest = rest[1:]
|
|
end = rest.find("\n---")
|
|
if end < 0:
|
|
return ""
|
|
return rest[:end]
|
|
|
|
|
|
def _frontmatter_post_id(text: str) -> str | None:
|
|
for line in _frontmatter_block(text).splitlines():
|
|
match = _POST_ID_LINE.match(line.strip())
|
|
if match:
|
|
return match.group(1)
|
|
return None
|
|
|
|
|
|
def collect_existing_ids(vault: Path) -> tuple[set[str], dict[str, Path]]:
|
|
"""Return every archive post_id and spine dirs keyed by spine post_id."""
|
|
threads = vault / "archive" / "threads"
|
|
ids: set[str] = set()
|
|
spines: dict[str, Path] = {}
|
|
if not threads.is_dir():
|
|
return ids, spines
|
|
for md in threads.rglob("*.md"):
|
|
pid = _frontmatter_post_id(md.read_text(encoding="utf-8"))
|
|
if not pid:
|
|
continue
|
|
ids.add(pid)
|
|
if md.name == "index.md" and md.parent.parent.parent == threads:
|
|
spines[pid] = md.parent
|
|
return ids, spines
|
|
|
|
|
|
def unique_dir_name(parent: Path, base: str) -> str:
|
|
if not (parent / base).exists():
|
|
return base
|
|
n = 2
|
|
while (parent / f"{base}-{n}").exists():
|
|
n += 1
|
|
return f"{base}-{n}"
|
|
|
|
|
|
def _split_frontmatter(text: str) -> tuple[str, str]:
|
|
if not text.startswith("---"):
|
|
return "", text
|
|
end = text.find("\n---", 3)
|
|
if end < 0:
|
|
return "", text
|
|
closer = end + 4
|
|
fm = text[:closer]
|
|
body = text[closer:]
|
|
if body.startswith("\n"):
|
|
fm += "\n"
|
|
body = body[1:]
|
|
elif not fm.endswith("\n"):
|
|
fm += "\n"
|
|
return fm, body
|
|
|
|
|
|
def _upsert_wikilink(path: Path, target: str) -> None:
|
|
text = path.read_text(encoding="utf-8")
|
|
fm, body = _split_frontmatter(text)
|
|
kept: list[str] = []
|
|
names: list[str] = []
|
|
for line in body.splitlines():
|
|
match = _WIKILINK_ITEM.match(line)
|
|
if match:
|
|
name = match.group(1)
|
|
if name not in names:
|
|
names.append(name)
|
|
else:
|
|
kept.append(line)
|
|
if target not in names:
|
|
names.append(target)
|
|
names.sort()
|
|
while kept and kept[0] == "":
|
|
kept.pop(0)
|
|
while kept and kept[-1] == "":
|
|
kept.pop()
|
|
parts: list[str] = []
|
|
if kept:
|
|
parts.extend(kept)
|
|
parts.append("")
|
|
parts.extend(f"- [[{name}]]" for name in names)
|
|
parts.append("")
|
|
body_text = "\n".join(parts)
|
|
if fm:
|
|
path.write_text(fm.rstrip() + "\n\n" + body_text, encoding="utf-8", newline="\n")
|
|
else:
|
|
path.write_text(body_text, encoding="utf-8", newline="\n")
|
|
|
|
|
|
def ensure_handle_index(vault: Path, handle: str, dir_name: str) -> None:
|
|
path = vault / "archive" / "threads" / handle / "index.md"
|
|
if not path.is_file():
|
|
front = (
|
|
"---\n"
|
|
f"title: {handle}\n"
|
|
"type: note\n"
|
|
"status: published\n"
|
|
"draft: false\n"
|
|
f"description: Archived threads by {handle}.\n"
|
|
"tags:\n"
|
|
" - archive\n"
|
|
" - twitter\n"
|
|
f" - {handle}\n"
|
|
"---\n"
|
|
)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(
|
|
front + f"\n- [[{dir_name}]]\n",
|
|
encoding="utf-8",
|
|
newline="\n",
|
|
)
|
|
return
|
|
_upsert_wikilink(path, dir_name)
|
|
|
|
|
|
def ensure_threads_index(vault: Path, handle: str) -> None:
|
|
path = vault / "archive" / "threads" / "index.md"
|
|
if not path.is_file():
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(
|
|
(
|
|
"---\n"
|
|
"title: Threads\n"
|
|
"type: note\n"
|
|
"status: published\n"
|
|
"draft: false\n"
|
|
"description: Archived Twitter/X threads and similar sequential posts.\n"
|
|
"tags:\n"
|
|
" - archive\n"
|
|
"---\n"
|
|
"\n"
|
|
"Threads are filed by author handle, then by date and title. "
|
|
"The main note in each folder is that author's own chain. "
|
|
"Replies live beside it.\n"
|
|
"\n"
|
|
"How to read a folder: [[How threads are organized]].\n"
|
|
"\n"
|
|
"Example of the note shape (single spine, no branches): "
|
|
"[[How to archive a thread]]\n"
|
|
"\n"
|
|
f"- [[{handle}]]\n"
|
|
),
|
|
encoding="utf-8",
|
|
newline="\n",
|
|
)
|
|
return
|
|
_upsert_wikilink(path, handle)
|
|
|
|
|
|
def discover_dumps(root: Path) -> list[Path]:
|
|
dumps = [
|
|
path for path in root.iterdir()
|
|
if path.is_dir() and (path / "thread_data.json").is_file()
|
|
]
|
|
dumps.sort(key=lambda path: path.name)
|
|
return dumps
|
|
|
|
|
|
def _gap_line(
|
|
post_id: str,
|
|
handle: str,
|
|
missing: list[str],
|
|
empty: list[str],
|
|
) -> str:
|
|
miss = ",".join(missing) if missing else "-"
|
|
empt = ",".join(empty) if empty else "-"
|
|
return f"{post_id} {handle} root {miss} {empt}"
|
|
|
|
|
|
def emit(
|
|
input_dir: Path,
|
|
vault: Path,
|
|
slug: str | None,
|
|
archived: str,
|
|
*,
|
|
force: bool = False,
|
|
reuse_dir: Path | None = None,
|
|
tip: str | None = None,
|
|
) -> EmitResult:
|
|
src = input_dir / "thread_data.json"
|
|
if not src.is_file():
|
|
raise SystemExit(f"missing {src}")
|
|
|
|
thread = load_thread(src)
|
|
if not thread.posts:
|
|
raise SystemExit(f"no posts in {src}")
|
|
|
|
if tip:
|
|
spine = spine_from_tip(thread, tip)
|
|
input_kind = "tip"
|
|
else:
|
|
spine = spine_ids(thread)
|
|
input_kind = "root"
|
|
roots = branch_roots(thread, spine)
|
|
ids = by_id(thread)
|
|
kids = children_map(thread)
|
|
first = ids[spine[0]]
|
|
handle = first.handle
|
|
first_line = (first.text or "").split("\n", 1)[0]
|
|
|
|
if reuse_dir is None and force:
|
|
_existing_ids, spines = collect_existing_ids(vault)
|
|
reuse_dir = spines.get(thread.root_post_id)
|
|
|
|
if reuse_dir is not None:
|
|
dir_name = reuse_dir.name
|
|
handle = reuse_dir.parent.name
|
|
note_dir = reuse_dir
|
|
asset_dir = vault / "assets" / "threads" / handle / dir_name
|
|
else:
|
|
dir_name = thread_dir_name(date_prefix(first.timestamp), first_line, slug)
|
|
if slug is None:
|
|
dir_name = unique_dir_name(
|
|
vault / "archive" / "threads" / handle,
|
|
dir_name,
|
|
)
|
|
note_dir = vault / "archive" / "threads" / handle / dir_name
|
|
asset_dir = vault / "assets" / "threads" / handle / dir_name
|
|
|
|
branch_names: dict[str, str] = {}
|
|
for rid in roots:
|
|
branch = ids[rid]
|
|
fname = branch_file_name(
|
|
date_prefix(branch.timestamp),
|
|
branch.handle,
|
|
branch.text,
|
|
)
|
|
name = fname[:-3] if fname.endswith(".md") else fname
|
|
branch_names[rid] = name
|
|
|
|
note_dir.mkdir(parents=True, exist_ok=True)
|
|
asset_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
items, media_by_post = collect_media(thread, input_dir, asset_dir)
|
|
|
|
(note_dir / "index.md").write_text(
|
|
render_spine(
|
|
thread,
|
|
spine,
|
|
roots,
|
|
branch_names,
|
|
archived,
|
|
media_by_post,
|
|
),
|
|
encoding="utf-8",
|
|
newline="\n",
|
|
)
|
|
for rid in roots:
|
|
(note_dir / f"{branch_names[rid]}.md").write_text(
|
|
render_branch(
|
|
thread,
|
|
rid,
|
|
descendants(rid, kids),
|
|
first.handle,
|
|
archived,
|
|
media_by_post,
|
|
),
|
|
encoding="utf-8",
|
|
newline="\n",
|
|
)
|
|
|
|
shutil.copy2(src, asset_dir / "thread_data.json")
|
|
media_doc = {
|
|
"root_post_id": thread.root_post_id,
|
|
"items": items,
|
|
}
|
|
(asset_dir / "media.json").write_text(
|
|
json.dumps(media_doc, indent=2) + "\n",
|
|
encoding="utf-8",
|
|
newline="\n",
|
|
)
|
|
(asset_dir / "gaps.md").write_text(
|
|
render_gaps(thread, spine, input_kind=input_kind),
|
|
encoding="utf-8",
|
|
newline="\n",
|
|
)
|
|
|
|
ensure_handle_index(vault, handle, dir_name)
|
|
ensure_threads_index(vault, handle)
|
|
|
|
return EmitResult(
|
|
post_id=thread.root_post_id,
|
|
handle=handle,
|
|
dir_name=dir_name,
|
|
branch_count=len(roots),
|
|
)
|
|
|
|
|
|
def emit_all(
|
|
dumps_root: Path,
|
|
vault: Path,
|
|
archived: str,
|
|
force: bool,
|
|
) -> tuple[int, int, int, int]:
|
|
dumps = discover_dumps(dumps_root)
|
|
existing_ids, spines = collect_existing_ids(vault)
|
|
lines: list[str] = []
|
|
skipped = 0
|
|
written = 0
|
|
errors = 0
|
|
|
|
for dump in dumps:
|
|
src = dump / "thread_data.json"
|
|
try:
|
|
thread = load_thread(src)
|
|
if not thread.posts:
|
|
raise ValueError(f"no posts in {src}")
|
|
spine = spine_ids(thread)
|
|
handle = by_id(thread)[spine[0]].handle
|
|
missing = missing_parent_ids(thread)
|
|
empty = empty_text_ids(thread)
|
|
post_id = thread.root_post_id
|
|
except Exception:
|
|
lines.append(f"{dump.name} ? error - -")
|
|
errors += 1
|
|
continue
|
|
|
|
lines.append(_gap_line(post_id, handle, missing, empty))
|
|
|
|
if post_id in existing_ids and not force:
|
|
skipped += 1
|
|
continue
|
|
|
|
reuse = spines.get(post_id) if force else None
|
|
try:
|
|
emit(
|
|
dump,
|
|
vault,
|
|
None,
|
|
archived,
|
|
force=force,
|
|
reuse_dir=reuse,
|
|
)
|
|
except Exception:
|
|
errors += 1
|
|
continue
|
|
|
|
written += 1
|
|
existing_ids.add(post_id)
|
|
|
|
out = vault / "assets" / "threads" / "_batch_gaps.md"
|
|
out.parent.mkdir(parents=True, exist_ok=True)
|
|
out.write_text("\n".join(lines) + "\n", encoding="utf-8", newline="\n")
|
|
return len(dumps), skipped, written, errors
|
|
|
|
|
|
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(
|
|
description=(
|
|
"Emit Threadwell archive notes from a thread_data.json dump. "
|
|
"No fetch, no lift."
|
|
),
|
|
)
|
|
source = parser.add_mutually_exclusive_group(required=True)
|
|
source.add_argument(
|
|
"--input",
|
|
type=Path,
|
|
help="Dump directory containing thread_data.json",
|
|
)
|
|
source.add_argument(
|
|
"--all",
|
|
dest="all_root",
|
|
type=Path,
|
|
metavar="DUMPS_ROOT",
|
|
help="Convert every dump directory under this root",
|
|
)
|
|
parser.add_argument(
|
|
"--vault",
|
|
required=True,
|
|
type=Path,
|
|
help="Threadwell vault root",
|
|
)
|
|
parser.add_argument(
|
|
"--slug",
|
|
default=None,
|
|
help="Optional slug override for a single --input (ignored with --all)",
|
|
)
|
|
parser.add_argument(
|
|
"--archived",
|
|
default=date.today().isoformat(),
|
|
help="Archive date YYYY-MM-DD (default: today local)",
|
|
)
|
|
parser.add_argument(
|
|
"--force",
|
|
action="store_true",
|
|
help="Emit even if post_id already exists in the vault",
|
|
)
|
|
parser.add_argument(
|
|
"--tip",
|
|
default=None,
|
|
help="Climb reply_to from this post_id for the spine (same handle)",
|
|
)
|
|
return parser.parse_args(argv)
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
args = parse_args(argv)
|
|
if args.all_root is not None:
|
|
seen, skipped, written, errors = emit_all(
|
|
args.all_root,
|
|
args.vault,
|
|
args.archived,
|
|
args.force,
|
|
)
|
|
print(
|
|
f"dumps seen={seen} skipped={skipped} "
|
|
f"written={written} errors={errors}"
|
|
)
|
|
return 0 if errors == 0 else 1
|
|
emit(
|
|
args.input,
|
|
args.vault,
|
|
args.slug,
|
|
args.archived,
|
|
force=args.force,
|
|
tip=args.tip,
|
|
)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|