going to test deep reply branch merge.

This commit is contained in:
ed committed 2026-08-24 23:26:44 -04:00
1 parent dc635997a9
commit 8741077173
40 files changed
+266 -72

No files matched your search

+4 -1
View File
@@ -9,6 +9,7 @@ python C:\projects\Threadwell\scripts\twitter\tw.py graph --id <snowflake>
python C:\projects\Threadwell\scripts\twitter\tw.py refresh --id <snowflake> --tip
python C:\projects\Threadwell\scripts\twitter\tw.py refresh --id <spine-tip> --tip --branch <branch-tip>
python C:\projects\Threadwell\scripts\twitter\tw.py add-branch --id <original-spine-tip> --from <reply-node>
python C:\projects\Threadwell\scripts\twitter\tw.py emit --id <spine-tip> --tip --preserve-existing
python C:\projects\Threadwell\scripts\twitter\tw.py audit-media --id <snowflake>
python C:\projects\Threadwell\scripts\twitter\tw.py publish --id <snowflake>
python C:\projects\Threadwell\scripts\twitter\tw.py sync --handle <handle>
@@ -23,7 +24,9 @@ python C:\projects\Threadwell\scripts\twitter\tw.py migrate-media --all --apply
`refresh` = refetch (cookies) + ingest + emit `--force` + media_merge. `--tip` treats `--id` as the tip and walks back to the OP regardless of handle. Repeatable `--branch <tip-id>` captures only those additional explicit tip paths and merges them into the same `thread_data.json`; it does not discover replies automatically. Captures run sequentially with gallery-dl retries disabled and five-second extractor/request pacing. Each thread emits exactly one archive directory owned by the OP (the post with `reply_to_id == None`); cross-author responders do not get a directory. Does not flip `draft`. Does not commit. Frozen ids abort.
`add-branch` incrementally extends an already emitted thread. `--id` must be the original stored spine tip; repeatable `--from <reply-node>` values are each captured once. The command locally retains each node's missing attachment path and entire visible descendant subtree, merges only previously absent posts into the existing `thread_data.json`, downloads media for the new posts, and re-emits with the original spine unchanged. It preserves existing origin/local/fallback/derived/OCR media records and adds provider-origin plus local-copy records for new branch media. A visible leaf is only the deepest reply returned by that capture; the command does not query descendants individually or claim provider-wide completeness. Fallback upload remains a separate explicit action after origin unavailability is confirmed.
`emit --preserve-existing` is the safe completion step after inspecting a fresh scratch capture. Fresh records win for ids the provider returned; posts present only in the existing emitted `thread_data.json` are retained after the command verifies both captures share a conversation root. This prevents an incomplete gallery-dl refresh from deleting archived replies. The merged scratch dataset keeps the requested `--id` as its stored spine tip before normal emission and media-manifest reconciliation.
`add-branch` incrementally extends an already emitted thread. `--id` must be the original stored spine tip; repeatable `--from <reply-node>` values are each captured once. The command locally retains each node's missing attachment path and entire visible descendant subtree, merges only previously absent posts into the existing `thread_data.json`, downloads media for the new posts, and re-emits with the original spine unchanged. It preserves existing origin/local/fallback/derived/OCR media records and adds provider-origin plus local-copy records for new branch media. A visible leaf is only the deepest reply returned by that capture; the command does not query descendants individually or claim provider-wide completeness. Gallery-dl may return only the ancestor chain when replies are behind nested show-more cursors; then the command adds nothing and known reply/stream-tip ids must be supplied explicitly. Fallback upload remains a separate explicit action after origin unavailability is confirmed.
Usual publish path after you like the note:
@@ -17,11 +17,13 @@ from twitter.tw import (
_copy_selected_media,
_gallery_base_args,
_merge_branch_posts,
_merge_existing_capture,
_merge_gallery_files,
_select_branch_capture,
_validate_capture_ids,
build_parser,
cmd_add_branch,
cmd_emit,
)
@@ -333,6 +335,107 @@ class TwMediaCommandTests(unittest.TestCase):
with self.assertRaisesRegex(SystemExit, "stored spine tip"):
cmd_add_branch(Namespace(id="200", from_ids=["300"]))
def test_merge_existing_capture_keeps_fresh_and_retains_missing(self) -> None:
old_100 = make_post("100", None)
old_200 = make_post("200", "100")
old_300 = make_post("300", "100")
existing = ThreadData(
root_post_id="100",
posts=(old_100, old_200, old_300),
source_url="https://x.com/i/status/100",
)
fresh_100 = replace(old_100, text="fresh root")
fresh_200 = replace(old_200, text="fresh tip")
fresh_400 = make_post("400", "200")
fresh = ThreadData(
root_post_id="200",
posts=(fresh_100, fresh_200, fresh_400),
source_url="https://x.com/i/status/200",
)
merged, retained = _merge_existing_capture(fresh, existing, "200")
self.assertEqual(
merged.posts,
(fresh_100, fresh_200, fresh_400, old_300),
)
self.assertEqual(merged.root_post_id, "200")
self.assertEqual(merged.source_url, fresh.source_url)
self.assertEqual(retained, ("300",))
def test_merge_existing_capture_rejects_different_conversation(self) -> None:
existing = ThreadData(
root_post_id="100",
posts=(make_post("100", None),),
source_url="https://x.com/i/status/100",
)
fresh = ThreadData(
root_post_id="200",
posts=(make_post("200", None),),
source_url="https://x.com/i/status/200",
)
with self.assertRaisesRegex(SystemExit, "different conversation"):
_merge_existing_capture(fresh, existing, "200")
def test_emit_and_refresh_accept_preserve_existing(self) -> None:
emit_args = build_parser().parse_args([
"emit", "--id", "200", "--tip", "--preserve-existing",
])
refresh_args = build_parser().parse_args([
"refresh", "--id", "200", "--tip", "--preserve-existing",
])
self.assertTrue(emit_args.preserve_existing)
self.assertTrue(refresh_args.preserve_existing)
def test_emit_preserve_existing_merges_before_running_emitter(self) -> None:
with TemporaryDirectory() as tmp:
root = Path(tmp)
scratch = root / "refetch_200"
assets = root / "assets"
notes = root / "notes"
notes.mkdir()
existing = ThreadData(
root_post_id="100",
posts=(
make_post("100", None),
make_post("200", "100"),
make_post("300", "100"),
),
source_url="https://x.com/i/status/100",
)
fresh = ThreadData(
root_post_id="200",
posts=(
replace(make_post("100", None), text="fresh"),
replace(make_post("200", "100"), text="fresh"),
make_post("400", "200"),
),
source_url="https://x.com/i/status/200",
)
write_test_thread(assets / "thread_data.json", existing)
write_test_thread(scratch / "thread_data.json", fresh)
with (
patch.object(tw_module, "scratch_dir", return_value=scratch),
patch.object(tw_module, "dump_dir", return_value=root / "dump"),
patch.object(tw_module, "locate", return_value=(assets, notes)),
patch.object(tw_module, "locate_all", return_value=[]),
patch.object(tw_module, "check_frozen"),
patch.object(tw_module, "run") as run,
redirect_stdout(StringIO()),
):
result = cmd_emit(Namespace(
id="200",
tip=True,
slug=None,
preserve_existing=True,
))
self.assertEqual(result, 0)
self.assertTrue(run.called)
merged = load_thread(scratch / "thread_data.json")
self.assertEqual(
tuple(post.post_id for post in merged.posts),
("100", "200", "400", "300"),
)
self.assertEqual(merged.root_post_id, "200")
def test_migrate_defaults_to_dry_run(self) -> None:
args = build_parser().parse_args(["migrate-media", "--id", "100"])
self.assertEqual(args.cmd, "migrate-media")
+47 -1
View File
@@ -320,6 +320,25 @@ def _merge_branch_posts(
)
def _merge_existing_capture(
fresh: ThreadData,
existing: ThreadData,
tip_id: str,
) -> tuple[ThreadData, tuple[str, ...]]:
"""Keep fresh post data while retaining archived posts it omitted."""
if tip_id not in {post.post_id for post in fresh.posts}:
raise SystemExit(f"spine tip {tip_id} missing from fresh capture")
fresh_roots = {
post.post_id for post in fresh.posts if post.reply_to_id is None
}
existing_roots = {
post.post_id for post in existing.posts if post.reply_to_id is None
}
if not fresh_roots or fresh_roots.isdisjoint(existing_roots):
raise SystemExit("fresh and existing captures are a different conversation")
return _merge_branch_posts(fresh, [existing.posts])
def _copy_selected_media(
source: Path,
destination: Path,
@@ -447,7 +466,12 @@ def cmd_add_branch(args: argparse.Namespace) -> int:
}
_copy_selected_media(capture_dir, output_dir, selected_added)
_write_thread(output_dir / "thread_data.json", merged)
cmd_emit(argparse.Namespace(id=post_id, tip=True, slug=None))
cmd_emit(argparse.Namespace(
id=post_id,
tip=True,
slug=None,
preserve_existing=False,
))
added_text = ",".join(added_ids) if added_ids else "-"
leaf_text = ";".join(
@@ -469,6 +493,23 @@ def cmd_emit(args: argparse.Namespace) -> int:
src = dump_dir(post_id)
if not (src / "thread_data.json").is_file():
raise SystemExit(f"no thread_data.json for {post_id}")
if args.preserve_existing:
assets, _notes = locate(post_id)
if assets is None:
raise SystemExit(f"no existing emitted thread for {post_id}")
existing_path = assets / "thread_data.json"
if not existing_path.is_file():
raise SystemExit(f"missing {existing_path}")
fresh = load_thread(src / "thread_data.json")
existing = load_thread(existing_path)
merged, retained_ids = _merge_existing_capture(
fresh,
existing,
post_id,
)
_write_thread(src / "thread_data.json", merged)
retained = ",".join(retained_ids) if retained_ids else "-"
print(f"preserve-existing retained={retained}")
cmd = [
sys.executable,
str(HERE / "emit_archive.py"),
@@ -795,6 +836,11 @@ def build_parser() -> argparse.ArgumentParser:
if name in ("emit", "refresh"):
p.add_argument("--tip", action="store_true", help="use --id as tip")
p.add_argument("--slug", default=None)
p.add_argument(
"--preserve-existing",
action="store_true",
help="retain archived posts omitted by the fresh capture",
)
if name == "lift":
p.add_argument("--orig", action="store_true")
if name == "ocr":