going to test deep reply branch merge.
This commit is contained in:
1 parent
dc635997a9
commit
8741077173
40 files changed
+266
-72
No files matched your search
@@ -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
@@ -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":
|
||||
|
||||
Reference in new issue
Block a user