re-render threads with user handle of reply.

This commit is contained in:
ed committed 2026-08-25 14:56:39 -04:00
1 parent 1ceb4b1583
commit f8380db665
515 files changed
+2042 -1496

No files matched your search

+5 -1
View File
@@ -17,6 +17,8 @@ python scripts/twitter/tw.py restore-origin --id <snowflake> --media-id <id>
python scripts/twitter/tw.py migrate-media --id <snowflake>
python scripts/twitter/tw.py reslug --all
python scripts/twitter/tw.py reslug --all --apply
python scripts/twitter/tw.py relabel --all
python scripts/twitter/tw.py relabel --all --apply
```
`paths.py` finds the vault two parents above this file. Dumps live at `../manual_slop/docs/twitter`. Scratch lives at `../Threadwell-ai/scratch`.
@@ -59,6 +61,8 @@ A thread folder is named from the title in `index.md`: `<YYYY-MM-DD>-<slug>`. `r
Later emits use that same title for the folder and the frontmatter. Move both sides together. Each old archive prefix is replaced once in mutable `*.md` and `*.canvas` outside `.git/`, `site/`, `assets/`, `secrets/`, `node_modules/`, and frozen archive dirs.
`relabel --all` patches existing `**N/**` lines to `**N/** @handle` from on-disk `thread_data.json`. No scrape. No folder rename. `--apply` writes notes only.
## Secrets
`secrets/twitter_cookies.txt` and `secrets/credentials.toml` stay on the machine that runs the capture. The scripts read them. They never print them. The Catbox hash goes in an HTTPS form body, not on a process command line. The backup command prints `synced` or `error`, not the destination path.
@@ -67,7 +71,7 @@ Later emits use that same title for the folder and the frontmatter. Move both si
| Script | Job |
|---|---|
| `tw.py` | locate / graph / refetch / add-branch / emit / refresh / ocr / merge / sync / publish / migrate-media / backup / fallback / restore-origin / reslug |
| `tw.py` | locate / graph / refetch / add-branch / emit / refresh / ocr / merge / sync / publish / migrate-media / backup / fallback / restore-origin / reslug / relabel |
| `graph_dry_run.py` | tip graph, no vault writes |
| `ingest_gallery.py` | gallery-dl JSON → `thread_data.json` |
| `emit_archive.py` | JSON → notes + assets. `--force` keeps derived and fallback rows |
+205
View File
@@ -780,6 +780,211 @@ def emit_all(
return len(dumps), skipped, written, errors
# --- relabel (patch **N/** lines with @handle from on-disk JSON) ---
_NUMBER_LINE: re.Pattern[str] = re.compile(
r"^(\*\*(\d+)/\*\*)(?: +@([A-Za-z0-9_]+))?[ \t]*$",
re.MULTILINE,
)
@dataclass(frozen=True)
class RelabelItem:
"""One note's in-place number-line patch outcome."""
path: Path
state: str
reason: str
@dataclass(frozen=True)
class RelabelPlan:
"""Corpus plan for patching ``**N/**`` lines. Notes only."""
vault: Path
items: tuple[RelabelItem, ...]
@property
def can_apply(self) -> bool:
return not any(item.state == "conflict" for item in self.items)
def _first_nonempty_line(text: str) -> str:
for line in text.splitlines():
stripped = line.strip()
if stripped:
return stripped
return ""
def _extract_post_body(after_number_line: str) -> str:
"""Text after a ``**N/**`` line, stopping at media, branches, or a heading."""
lines: list[str] = []
for line in after_number_line.lstrip("\n").splitlines():
if line.startswith("![") or line.startswith("<video"):
break
if line.startswith("Branches:"):
break
if line.startswith("## "):
break
if _NUMBER_LINE.match(line):
break
lines.append(line)
return "\n".join(lines).strip()
def _frontmatter_handle(text: str) -> str:
"""Return the note-root ``handle:`` value, or empty."""
in_front = False
for line in text.splitlines():
if line.strip() == "---":
if in_front:
return ""
in_front = True
continue
if in_front and line.startswith("handle:"):
return line.split(":", 1)[1].strip()
return ""
def match_post_for_body(
body: str,
posts: tuple,
used: set[str],
) -> object | None:
"""Return one unused post whose first line matches ``body``.
Duplicate first lines are fine when every hit has the same handle.
Mixed-handle collisions return ``None``.
"""
first = _first_nonempty_line(body)
if not first:
return None
hits = [
post
for post in posts
if post.post_id not in used
and _first_nonempty_line(post.text) == first
]
if not hits:
return None
handles = {str(post.handle or "") for post in hits}
if len(handles) > 1:
return None
hits.sort(key=lambda post: (post.timestamp, post.post_id))
return hits[0]
def patch_note_text(text: str, posts: tuple) -> tuple[str, str, str]:
"""Patch ``**N/**`` lines in ``text``. Returns ``(new_text, state, reason)``."""
matches = list(_NUMBER_LINE.finditer(text))
if not matches:
return text, "noop", "no number lines"
pieces: list[str] = []
last = 0
changed = False
unmatched: list[str] = []
used: set[str] = set()
root_handle = _frontmatter_handle(text)
for index, match in enumerate(matches):
pieces.append(text[last:match.start()])
number = match.group(2)
end = matches[index + 1].start() if index + 1 < len(matches) else len(text)
body = _extract_post_body(text[match.end():end])
post = match_post_for_body(body, posts, used)
handle = ""
if post is not None:
handle = str(post.handle or "")
if handle:
used.add(post.post_id)
if not handle and index == 0 and root_handle:
handle = root_handle
if not handle:
unmatched.append(f"**{number}/**")
pieces.append(match.group(0).rstrip())
last = match.end()
continue
new_line = f"**{number}/** @{handle}"
if new_line != match.group(0).rstrip():
changed = True
pieces.append(new_line)
last = match.end()
pieces.append(text[last:])
new_text = "".join(pieces)
reason = ",".join(unmatched)
if unmatched and not changed:
return text, "conflict", reason
if changed:
return new_text, "rewrite", reason
return text, "noop", ""
def plan_relabel(vault: Path) -> RelabelPlan:
"""Walk on-disk ``thread_data.json`` and classify each thread note."""
assets_root = vault / "assets" / "threads"
notes_root = vault / "archive" / "threads"
items: list[RelabelItem] = []
if not assets_root.is_dir():
return RelabelPlan(vault, ())
for data_path in sorted(assets_root.rglob("thread_data.json")):
relative = data_path.parent.relative_to(assets_root)
note_dir = notes_root / relative
if not note_dir.is_dir():
items.append(RelabelItem(note_dir, "conflict", "missing note dir"))
continue
try:
thread = load_thread(data_path)
except (OSError, ValueError, KeyError, TypeError) as exc:
items.append(RelabelItem(data_path, "conflict", str(exc)[:200]))
continue
notes = sorted(path for path in note_dir.glob("*.md") if path.is_file())
if not notes:
items.append(RelabelItem(note_dir, "conflict", "no notes"))
continue
for note_path in notes:
text = note_path.read_text(encoding="utf-8")
_new, state, reason = patch_note_text(text, thread.posts)
items.append(RelabelItem(note_path, state, reason))
return RelabelPlan(vault, tuple(items))
def apply_relabel(plan: RelabelPlan) -> None:
"""Write ``rewrite`` notes. Leave conflicts and JSON/media untouched."""
assets_root = plan.vault / "assets" / "threads"
notes_root = plan.vault / "archive" / "threads"
for item in plan.items:
if item.state != "rewrite":
continue
try:
relative = item.path.parent.relative_to(notes_root)
except ValueError:
continue
data_path = assets_root / relative / "thread_data.json"
if not data_path.is_file():
continue
thread = load_thread(data_path)
text = item.path.read_text(encoding="utf-8")
new_text, state, _reason = patch_note_text(text, thread.posts)
if state != "rewrite":
continue
item.path.write_text(new_text, encoding="utf-8", newline="\n")
def format_relabel_plan(plan: RelabelPlan) -> str:
"""Print one line per note plus counts."""
lines: list[str] = []
counts = {"rewrite": 0, "noop": 0, "conflict": 0}
for item in plan.items:
counts[item.state] = counts.get(item.state, 0) + 1
extra = f" {item.reason}" if item.reason else ""
lines.append(f"{item.state} {item.path}{extra}")
lines.append(
f"rewrite={counts.get('rewrite', 0)} "
f"noop={counts.get('noop', 0)} "
f"conflict={counts.get('conflict', 0)}"
)
return "\n".join(lines) + "\n"
# --- reslug (one-time bulk title/path reconciliation) ---
+4 -3
View File
@@ -106,9 +106,10 @@ def _post_block(
media_by_post: dict[str, tuple[str, ...]] | None,
branch_links: list[str] | None = None,
) -> str:
"""Render one post body (``**N/**`` + text + media embeds + branch links)."""
parts = [f"**{n}/**", "", post.text]
parts = [f"**{n}/**", "", post.text]
"""Render one post body (``**N/** @handle`` + text + media + branch links)."""
if not post.handle:
raise ValueError(f"empty handle on post {post.post_id}")
parts = [f"**{n}/** @{post.handle}", "", post.text]
media = (media_by_post or {}).get(post.post_id) or ()
if media:
parts.append("")
@@ -0,0 +1,299 @@
from __future__ import annotations
import unittest
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest.mock import patch
from twitter.emit_archive import apply_relabel, format_relabel_plan, plan_relabel
from twitter.models import PostData, PostMetrics, ThreadData
from twitter.render import _post_block, render_branch, render_spine
from twitter.tw import build_parser, cmd_relabel
def post(
post_id: str,
handle: str,
text: str,
reply_to_id: str | None,
) -> PostData:
return PostData(
post_id=post_id,
author=handle,
handle=handle,
text=text,
timestamp="2025-03-08 12:00:00",
media_urls=(),
reply_to_id=reply_to_id,
quote_of_id=None,
metrics=PostMetrics(0, 0, 0, None),
)
class PostBlockAttributionTests(unittest.TestCase):
def test_number_line_includes_handle(self) -> None:
block = _post_block(
19,
post("1898440015182729598", "SebAaltonen", "WG_RR_EN", "1"),
None,
)
self.assertTrue(block.startswith("**19/** @SebAaltonen\n"))
self.assertIn("WG_RR_EN", block)
def test_empty_handle_raises(self) -> None:
with self.assertRaises(ValueError):
_post_block(1, post("1", "", "text", None), None)
class RenderAttributionTests(unittest.TestCase):
def test_mixed_spine_labels_each_post(self) -> None:
thread = ThreadData(
root_post_id="1",
posts=(
post("1", "rianflo", "The road to 16-bit", None),
post("2", "NOTimothyLottes", "Explicit packed 16-bit", "1"),
post("3", "rianflo", "Oh I know the benefits", "2"),
),
source_url="https://x.com/rianflo/status/1",
)
text = render_spine(thread, ["1", "2", "3"], [], {}, "2026-08-25")
self.assertIn("**1/** @rianflo", text)
self.assertIn("**2/** @NOTimothyLottes", text)
self.assertIn("**3/** @rianflo", text)
def test_mixed_branch_labels_seb_and_jebrim(self) -> None:
thread = ThreadData(
root_post_id="root",
posts=(
post("root", "SebAaltonen", "Stupid hardware question", None),
post(
"1898352640825651644",
"NOTimothyLottes",
"@SebAaltonen Actually this is a super important question IMO.",
"root",
),
post(
"1898440015182729598",
"SebAaltonen",
"@NOTimothyLottes I said WG_RR_EN",
"1898352640825651644",
),
post(
"1898385745892495810",
"AgileJebrim",
"On the flip side, it appears that using 128",
"1898352640825651644",
),
),
source_url="https://x.com/SebAaltonen/status/root",
)
text = render_branch(
thread,
"1898352640825651644",
["1898440015182729598", "1898385745892495810"],
"SebAaltonen",
"2026-08-25",
)
self.assertIn("**1/** @NOTimothyLottes", text)
self.assertIn("**2/** @SebAaltonen", text)
self.assertIn("**3/** @AgileJebrim", text)
for line in text.splitlines():
if line.startswith("**") and "/**" in line:
self.assertRegex(line, r"^\*\*\d+/\*\* @[A-Za-z0-9_]+$")
class RelabelPatchTests(unittest.TestCase):
def _vault(self, raw: str) -> tuple[Path, Path, Path]:
root = Path(raw)
asset = (
root / "assets" / "threads" / "SebAaltonen" / "2025-03-08-amd"
)
notes = (
root / "archive" / "threads" / "SebAaltonen" / "2025-03-08-amd"
)
asset.mkdir(parents=True)
notes.mkdir(parents=True)
(asset / "thread_data.json").write_text(
"""{
"root_post_id": "root",
"source_url": "https://x.com/SebAaltonen/status/root",
"posts": [
{
"post_id": "root",
"author": "SebAaltonen",
"handle": "SebAaltonen",
"text": "Stupid hardware question",
"timestamp": "2025-03-08 07:20:39",
"media_urls": [],
"reply_to_id": null,
"quote_of_id": null,
"metrics": {"reply_count": 0, "repost_count": 0, "like_count": 0, "view_count": 1}
},
{
"post_id": "1898440015182729598",
"author": "SebAaltonen",
"handle": "SebAaltonen",
"text": "@NOTimothyLottes I said WG_RR_EN",
"timestamp": "2025-03-08 18:00:00",
"media_urls": [],
"reply_to_id": "1898352640825651644",
"quote_of_id": null,
"metrics": {"reply_count": 0, "repost_count": 0, "like_count": 0, "view_count": 1}
},
{
"post_id": "1898385745892495810",
"author": "AgileJebrim",
"handle": "AgileJebrim",
"text": "On the flip side, it appears that using 128",
"timestamp": "2025-03-08 17:00:00",
"media_urls": [],
"reply_to_id": "1898352640825651644",
"quote_of_id": null,
"metrics": {"reply_count": 0, "repost_count": 0, "like_count": 0, "view_count": 1}
}
]
}
""",
encoding="utf-8",
newline="\n",
)
(asset / "media.json").write_text(
'{"schema_version": 2, "root_post_id": "root", "items": []}\n',
encoding="utf-8",
newline="\n",
)
(notes / "index.md").write_text(
"---\ndraft: false\ntags:\n - archive\n - vulkan\n---\n\n"
"## Thread\n\n"
"**1/**\n\n"
"Stupid hardware question\n",
encoding="utf-8",
newline="\n",
)
(notes / "branch.md").write_text(
"---\ndraft: false\n---\n\n"
"## Branch\n\n"
"**19/**\n\n"
"@NOTimothyLottes I said WG_RR_EN\n\n"
"**33/**\n\n"
"On the flip side, it appears that using 128\n\n"
"## Visible chain\n\n"
"1. **Onat Turkcuoglu** (@onatt0) - Apr 30\n"
" holy truthnuke\n",
encoding="utf-8",
newline="\n",
)
return root, asset, notes
def test_plan_rewrites_unlabeled_number_lines(self) -> None:
with TemporaryDirectory() as raw:
vault, asset, notes = self._vault(raw)
plan = plan_relabel(vault)
states = {item.path.name: item.state for item in plan.items}
self.assertEqual(states["index.md"], "rewrite")
self.assertEqual(states["branch.md"], "rewrite")
self.assertTrue(all(item.path.suffix == ".md" for item in plan.items))
self.assertFalse(
any(item.path.name == "thread_data.json" for item in plan.items)
)
json_before = (asset / "thread_data.json").read_bytes()
apply_relabel(plan)
self.assertEqual((asset / "thread_data.json").read_bytes(), json_before)
self.assertEqual((asset / "media.json").read_bytes(), (
b'{"schema_version": 2, "root_post_id": "root", "items": []}\n'
))
branch = (notes / "branch.md").read_text(encoding="utf-8")
self.assertIn("**19/** @SebAaltonen", branch)
self.assertIn("**33/** @AgileJebrim", branch)
self.assertIn("1. **Onat Turkcuoglu** (@onatt0) - Apr 30", branch)
index = (notes / "index.md").read_text(encoding="utf-8")
self.assertIn("**1/** @SebAaltonen", index)
self.assertIn(" - vulkan", index)
self.assertIn("draft: false", index)
def test_already_labeled_is_noop(self) -> None:
with TemporaryDirectory() as raw:
vault, _asset, notes = self._vault(raw)
(notes / "index.md").write_text(
"---\ndraft: false\n---\n\n**1/** @SebAaltonen\n\n"
"Stupid hardware question\n",
encoding="utf-8",
newline="\n",
)
(notes / "branch.md").write_text(
"---\ndraft: false\n---\n\n"
"**19/** @SebAaltonen\n\n"
"@NOTimothyLottes I said WG_RR_EN\n\n"
"**33/** @AgileJebrim\n\n"
"On the flip side, it appears that using 128\n",
encoding="utf-8",
newline="\n",
)
plan = plan_relabel(vault)
self.assertTrue(all(item.state == "noop" for item in plan.items))
before = (notes / "index.md").read_bytes()
apply_relabel(plan)
self.assertEqual((notes / "index.md").read_bytes(), before)
def test_duplicate_first_line_same_handle_rewrites(self) -> None:
from twitter.emit_archive import patch_note_text
posts = (
post("1", "simplex_fx", "How big?", None),
post("2", "simplex_fx", "How big?", "1"),
)
text = "**30/**\n\nHow big?\n\n**31/**\n\nHow big?\n"
new, state, reason = patch_note_text(text, posts)
self.assertEqual(state, "rewrite")
self.assertEqual(reason, "")
self.assertIn("**30/** @simplex_fx", new)
self.assertIn("**31/** @simplex_fx", new)
def test_empty_handle_is_conflict(self) -> None:
with TemporaryDirectory() as raw:
vault, asset, notes = self._vault(raw)
text = (asset / "thread_data.json").read_text(encoding="utf-8")
(asset / "thread_data.json").write_text(
text.replace('"handle": "SebAaltonen"', '"handle": ""', 1),
encoding="utf-8",
)
plan = plan_relabel(vault)
self.assertTrue(any(item.state == "conflict" for item in plan.items))
before = (notes / "index.md").read_bytes()
apply_relabel(plan)
self.assertEqual((notes / "index.md").read_bytes(), before)
def test_parser_and_dry_run_do_not_apply(self) -> None:
args = build_parser().parse_args(["relabel", "--all"])
self.assertFalse(args.apply)
args = build_parser().parse_args(["relabel", "--all", "--apply"])
self.assertTrue(args.apply)
with TemporaryDirectory() as raw:
vault, _asset, notes = self._vault(raw)
before = (notes / "branch.md").read_bytes()
with patch("twitter.tw.VAULT", vault):
code = cmd_relabel(
build_parser().parse_args(["relabel", "--all"])
)
self.assertEqual(code, 0)
self.assertEqual((notes / "branch.md").read_bytes(), before)
class RelabelFormatTests(unittest.TestCase):
def test_format_lists_states(self) -> None:
from twitter.emit_archive import RelabelItem, RelabelPlan
plan = RelabelPlan(
vault=Path("v"),
items=(
RelabelItem(Path("v/a.md"), "rewrite", ""),
RelabelItem(Path("v/b.md"), "noop", ""),
),
)
text = format_relabel_plan(plan)
self.assertIn("rewrite", text)
self.assertIn("noop", text)
if __name__ == "__main__":
unittest.main()
+37 -2
View File
@@ -35,7 +35,14 @@ try:
from frozen import frozen_match, load_frozen_ids, require_writable
from backup_assets import backup_thread, load_destination_root
from catbox_client import load_userhash, upload_file
from emit_archive import apply_reslug_plan, format_reslug_plan, plan_reslug
from emit_archive import (
apply_relabel,
apply_reslug_plan,
format_relabel_plan,
format_reslug_plan,
plan_relabel,
plan_reslug,
)
from fallback_media import activate_fallback, find_existing_fallback, restore_origin
from media_audit import audit_thread
from media_migrate import migrate_legacy_thread
@@ -43,7 +50,14 @@ except ImportError: # pragma: no cover - script-mode import
from frozen import frozen_match, load_frozen_ids, require_writable
from backup_assets import backup_thread, load_destination_root
from catbox_client import load_userhash, upload_file
from emit_archive import apply_reslug_plan, format_reslug_plan, plan_reslug
from emit_archive import (
apply_relabel,
apply_reslug_plan,
format_relabel_plan,
format_reslug_plan,
plan_relabel,
plan_reslug,
)
from fallback_media import activate_fallback, find_existing_fallback, restore_origin
from media_audit import audit_thread
from media_migrate import migrate_legacy_thread
@@ -811,6 +825,19 @@ def cmd_reslug(args: argparse.Namespace) -> int:
return 0
def cmd_relabel(args: argparse.Namespace) -> int:
"""Patch ``**N/**`` lines from on-disk JSON. Notes only."""
if not args.all_threads:
raise SystemExit("relabel requires --all")
plan = plan_relabel(VAULT)
print(format_relabel_plan(plan), end="")
conflicts = any(item.state == "conflict" for item in plan.items)
if not args.apply:
return 2 if conflicts else 0
apply_relabel(plan)
return 2 if conflicts else 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
sub = parser.add_subparsers(dest="cmd", required=True)
@@ -916,6 +943,13 @@ def build_parser() -> argparse.ArgumentParser:
reslug.add_argument("--all", dest="all_threads", action="store_true", required=True)
reslug.add_argument("--apply", action="store_true")
relabel = sub.add_parser(
"relabel",
help="patch **N/** lines with @handle from on-disk JSON",
)
relabel.add_argument("--all", dest="all_threads", action="store_true", required=True)
relabel.add_argument("--apply", action="store_true")
return parser
@@ -937,6 +971,7 @@ _COMMANDS: dict[str, Callable[[argparse.Namespace], int]] = {
"fallback": cmd_fallback,
"restore-origin": cmd_restore_origin,
"reslug": cmd_reslug,
"relabel": cmd_relabel,
}