re-render threads with user handle of reply.
This commit is contained in:
1 parent
1ceb4b1583
commit
f8380db665
515 files changed
+2042
-1496
No files matched your search
@@ -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 |
|
||||
|
||||
@@ -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) ---
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user