Scripts update: new media convention.

This commit is contained in:
ed committed 2026-08-24 14:43:29 -04:00
1 parent 2945eca13c
commit 24a82aa46c
16 files changed
+1897 -443

No files matched your search

+1
View File
@@ -21,3 +21,4 @@ assets/*
secrets/*
!secrets/README.md
scripts/twitter/__pycache__
scripts/twitter/tests/__pycache__
+122
View File
@@ -0,0 +1,122 @@
from __future__ import annotations
import json
import shutil
import sys
import tomllib
from dataclasses import dataclass
from pathlib import Path
try:
from .media_manifest import (
atomic_write_json,
hash_file,
inventory_digest,
payload_inventory,
)
except ImportError: # pragma: no cover - script-mode import
if str(Path(__file__).resolve().parent) not in sys.path:
sys.path.insert(0, str(Path(__file__).resolve().parent))
from media_manifest import (
atomic_write_json,
hash_file,
inventory_digest,
payload_inventory,
)
@dataclass(frozen=True)
class BackupResult:
state: str
destination: Path
error: str | None
def load_destination_root(vault: Path, destination_id: str) -> Path:
path = vault / "secrets" / "credentials.toml"
if not path.is_file():
raise RuntimeError(f"missing {path}")
text = path.read_text(encoding="utf-8")
try:
data = tomllib.loads(text)
backup = data.get("backup") or {}
configured_id = str(backup.get("id") or "cozy")
root = str(backup.get("root") or "").strip()
except tomllib.TOMLDecodeError:
values: dict[str, str] = {}
in_backup = False
for raw in text.splitlines():
line = raw.strip()
if line.startswith("[") and line.endswith("]"):
in_backup = line[1:-1].strip().lower() == "backup"
continue
if in_backup and "=" in line:
key, _equals, remainder = line.partition("=")
values[key.strip().lower()] = remainder.strip().strip("\"'")
configured_id = values.get("id", "cozy")
root = values.get("root", "")
if configured_id != destination_id:
raise RuntimeError(f"backup destination not configured: {destination_id}")
if not root:
raise RuntimeError("credentials.toml [backup] root is empty")
return Path(root)
def backup_thread(
asset_dir: Path,
*,
assets_root: Path,
destination_root: Path,
destination_id: str,
now: str,
require_destination_root: bool = False,
) -> BackupResult:
media_path = asset_dir / "media.json"
manifest = json.loads(media_path.read_text(encoding="utf-8"))
relative = asset_dir.relative_to(assets_root)
destination = destination_root / relative
inventory = payload_inventory(asset_dir, manifest)
digest = inventory_digest(inventory)
mirror = {
"destination_id": destination_id,
"relative_path": relative.as_posix(),
"state": "error",
"inventory_digest": digest,
"last_attempted_at": now,
"completed_at": None,
"verified_at": None,
"error": None,
}
others = [
entry
for entry in manifest.get("mirrors") or []
if entry.get("destination_id") != destination_id
]
try:
if require_destination_root and not destination_root.is_dir():
raise OSError(f"destination root unavailable: {destination_root}")
destination.mkdir(parents=True, exist_ok=True)
for source in sorted(asset_dir.iterdir(), key=lambda path: path.name):
if source.is_file():
shutil.copy2(source, destination / source.name)
for name, expected in inventory.items():
if name == "media.json#canonical":
continue
copied = destination / name
if not copied.is_file() or hash_file(copied) != expected:
raise OSError(f"backup verification failed: {name}")
mirror["state"] = "synced"
mirror["completed_at"] = now
mirror["verified_at"] = now
except OSError as exc:
mirror["error"] = str(exc)[:300]
manifest["mirrors"] = others + [mirror]
atomic_write_json(media_path, manifest)
if mirror["state"] == "synced":
shutil.copy2(media_path, destination / "media.json")
if hash_file(media_path) != hash_file(destination / "media.json"):
mirror["state"] = "error"
mirror["error"] = "final manifest verification failed"
atomic_write_json(media_path, manifest)
return BackupResult("error", destination, mirror["error"])
return BackupResult(mirror["state"], destination, mirror["error"])
+58
View File
@@ -0,0 +1,58 @@
from __future__ import annotations
import subprocess
import tomllib
from pathlib import Path
def load_userhash(vault: Path) -> str:
path = vault / "secrets" / "credentials.toml"
if not path.is_file():
raise RuntimeError(f"missing {path}")
text = path.read_text(encoding="utf-8")
try:
data = tomllib.loads(text)
value = str((data.get("catbox") or {}).get("userhash") or "").strip()
except tomllib.TOMLDecodeError:
value = ""
in_catbox = False
for raw in text.splitlines():
line = raw.strip()
if line.startswith("[") and line.endswith("]"):
in_catbox = line[1:-1].strip().lower() == "catbox"
continue
if in_catbox and line.lower().startswith("userhash"):
_key, _equals, remainder = line.partition("=")
value = remainder.strip().strip("\"'")
break
if not value:
raise RuntimeError("credentials.toml [catbox] userhash is empty")
return value
def upload_file(path: Path, userhash: str) -> str:
cmd = [
"curl.exe",
"-sS",
"--max-time",
"180",
"--limit-rate",
"200k",
"-F",
"reqtype=fileupload",
"-F",
f"userhash={userhash}",
"-F",
f"fileToUpload=@{path}",
"https://catbox.moe/user/api.php",
]
try:
raw = subprocess.check_output(cmd, text=True, stderr=subprocess.STDOUT)
except subprocess.CalledProcessError as exc:
raise RuntimeError(
f"catbox upload failed for {path.name} (curl exit {exc.returncode})"
) from None
url = raw.strip()
if not url.startswith("https://"):
raise RuntimeError(f"catbox refused {path.name}: non-URL response")
return url
+205 -92
View File
@@ -14,17 +14,121 @@ 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,
)
try:
from twitter.media_manifest import (
atomic_write_json,
find_location,
item_key,
merge_manifest_items,
new_original_item,
selected_url,
)
from twitter.media_refs import remote_markup
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,
)
except ImportError: # pragma: no cover - script-mode import
from media_manifest import (
atomic_write_json,
find_location,
item_key,
merge_manifest_items,
new_original_item,
selected_url,
)
from media_refs import remote_markup
from models import ThreadData, load_thread
from render import render_branch, render_spine
from slug import branch_file_name, date_prefix, thread_dir_name
from tree import (
by_id,
branch_roots,
children_map,
descendants,
spine_from_tip,
spine_ids,
)
def _frontmatter_lines(text: str) -> tuple[list[str], str]:
if not text.startswith("---\n"):
return [], text
end = text.find("\n---", 4)
if end < 0:
return [], text
return text[4:end].splitlines(), text[end + 4 :]
def _tag_values(lines: list[str]) -> list[str]:
tags: list[str] = []
in_tags = False
for line in lines:
if line == "tags:":
in_tags = True
continue
if in_tags and line.startswith(" - "):
tags.append(line[4:])
continue
if in_tags and line and not line.startswith(" "):
break
return tags
def preserve_review_state(
old: str,
fresh: str,
*,
mechanical_tags: set[str],
) -> str:
old_lines, _old_body = _frontmatter_lines(old)
fresh_lines, fresh_body = _frontmatter_lines(fresh)
if not old_lines or not fresh_lines:
return fresh
old_draft = next(
(line for line in old_lines if line.startswith("draft: ")),
"draft: true",
)
reviewed = [tag for tag in _tag_values(old_lines) if tag not in mechanical_tags]
output: list[str] = []
in_tags = False
inserted_reviewed = False
for line in fresh_lines:
if line.startswith("draft: "):
output.append(old_draft)
continue
if line == "tags:":
in_tags = True
output.append(line)
continue
if in_tags and line.startswith(" - "):
output.append(line)
continue
if in_tags and not inserted_reviewed:
output.extend(f" - {tag}" for tag in reviewed if f" - {tag}" not in output)
inserted_reviewed = True
in_tags = False
output.append(line)
if in_tags and not inserted_reviewed:
output.extend(f" - {tag}" for tag in reviewed if f" - {tag}" not in output)
return "---\n" + "\n".join(output) + "\n---" + fresh_body
def missing_local_media(items: list[dict[str, object]]) -> list[str]:
missing: list[str] = []
for item in items:
local = find_location(item, "local")
if local is not None and local.get("integrity") == "missing":
post_id, media_id, role = item_key(item)
missing.append(f"{post_id}/{media_id}/{role}")
return missing
_POST_ID_LINE = re.compile(r'^post_id:\s*"?([^"\s]+)"?\s*$')
_WIKILINK_ITEM = re.compile(r"^- \[\[([^\]|]+)\]\]\s*$")
@@ -36,6 +140,7 @@ class EmitResult:
handle: str
dir_name: str
branch_count: int
missing_media_count: int
def _url_media_id(url: str, index: int) -> str:
@@ -80,11 +185,11 @@ def collect_media(
thread: ThreadData,
input_dir: Path,
dest_dir: Path,
) -> tuple[list[dict[str, object]], dict[str, tuple[str, ...]]]:
*,
now: str,
) -> list[dict[str, object]]:
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)
@@ -97,67 +202,60 @@ def collect_media(
else:
ext = _url_ext(url) or "bin"
filename = f"{post.post_id}_{media_id}_orig.{ext}"
local_path = dest_dir / filename
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,
"embed": True,
})
by_post.setdefault(post.post_id, []).append(filename)
shutil.copy2(src, local_path)
items.append(
new_original_item(
post_id=post.post_id,
media_id=media_id,
handle=post.handle,
origin_url=url,
filename=filename,
local_path=local_path,
now=now,
)
)
return items
media_by_post = {pid: tuple(names) for pid, names in by_post.items()}
return items, media_by_post
def selected_media_by_post(
items: list[dict[str, object]],
) -> dict[str, tuple[str, ...]]:
grouped: dict[str, list[str]] = {}
for item in items:
if not item.get("embed"):
continue
url = selected_url(item)
if url is not None:
grouped.setdefault(str(item.get("post_id") or ""), []).append(url)
return {post_id: tuple(urls) for post_id, urls in grouped.items()}
def merge_existing_media(
dest_dir: Path,
items: list[dict[str, object]],
media_by_post: dict[str, tuple[str, ...]],
) -> tuple[list[dict[str, object]], dict[str, tuple[str, ...]]]:
"""Keep CRT/OCR rows and prior lift URLs across --force emit."""
) -> list[dict[str, object]]:
"""Merge existing derived/fallback rows with fresh canonical items."""
old_path = dest_dir / "media.json"
if not old_path.is_file():
return items, media_by_post
return items
try:
old = json.loads(old_path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
return items, media_by_post
by_file = {str(i.get("filename") or ""): i for i in items}
for row in old.get("items") or []:
if not isinstance(row, dict):
continue
fn = str(row.get("filename") or "")
role = str(row.get("role") or "")
if role == "orig" and fn in by_file:
cur = by_file[fn]
if row.get("url"):
cur["url"] = row["url"]
if row.get("publish"):
cur["publish"] = row["publish"]
if "embed" in row:
cur["embed"] = row["embed"]
continue
if role in ("", "orig"):
continue
if fn in by_file:
continue
if fn and (dest_dir / fn).is_file():
items.append(row)
by_post: dict[str, list[str]] = {}
for item in items:
if item.get("role") == "orig" and item.get("embed", True):
by_post.setdefault(str(item.get("post_id") or ""), []).append(
str(item.get("filename") or "")
)
media_by_post = {pid: tuple(names) for pid, names in by_post.items()}
return items, media_by_post
return items
if old.get("schema_version") != 2:
raise SystemExit(
"legacy media.json requires: tw.py migrate-media --id <id> --apply"
)
return merge_manifest_items(list(old.get("items") or []), items)
def _legacy_emit_message(*args: object, **kwargs: object) -> None:
raise SystemExit(
"legacy media.json requires: tw.py migrate-media --id <id> --apply"
)
def missing_parent_ids(thread: ThreadData) -> list[str]:
@@ -485,52 +583,66 @@ def emit(
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)
items, media_by_post = merge_existing_media(asset_dir, items, media_by_post)
now = archived + "T12:00:00Z"
fresh_items = collect_media(thread, input_dir, asset_dir, now=now)
items = merge_existing_media(asset_dir, fresh_items)
media_by_post = selected_media_by_post(items)
missing_keys = missing_local_media(items)
branch_links = {
rid: wiki_branch(handle, dir_name, name)
for rid, name in branch_names.items()
}
(note_dir / "index.md").write_text(
render_spine(
spine_text = render_spine(
thread,
spine,
roots,
branch_links,
archived,
media_by_post,
)
spine_path = note_dir / "index.md"
if spine_path.is_file():
spine_text = preserve_review_state(
spine_path.read_text(encoding="utf-8"),
spine_text,
mechanical_tags={"archive", "twitter", handle},
)
spine_path.write_text(spine_text, encoding="utf-8", newline="\n")
for rid in roots:
branch_path = note_dir / f"{branch_names[rid]}.md"
branch_text = render_branch(
thread,
spine,
roots,
branch_links,
rid,
descendants(rid, kids),
first.handle,
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,
spine_wiki=wiki_thread(handle, dir_name),
),
encoding="utf-8",
newline="\n",
spine_wiki=wiki_thread(handle, dir_name),
)
if branch_path.is_file():
branch_text = preserve_review_state(
branch_path.read_text(encoding="utf-8"),
branch_text,
mechanical_tags={"archive", "twitter", handle},
)
branch_path.write_text(branch_text, encoding="utf-8", newline="\n")
shutil.copy2(src, asset_dir / "thread_data.json")
media_doc = {
"schema_version": 2,
"root_post_id": thread.root_post_id,
"items": items,
"mirrors": [],
}
(asset_dir / "media.json").write_text(
json.dumps(media_doc, indent=2) + "\n",
encoding="utf-8",
newline="\n",
)
atomic_write_json(asset_dir / "media.json", media_doc)
gaps_text = render_gaps(thread, spine, input_kind=input_kind)
if missing_keys:
gaps_text += "\nmissing_media_local:\n" + "\n".join(missing_keys) + "\n"
(asset_dir / "gaps.md").write_text(
render_gaps(thread, spine, input_kind=input_kind),
gaps_text,
encoding="utf-8",
newline="\n",
)
@@ -543,6 +655,7 @@ def emit(
handle=handle,
dir_name=dir_name,
branch_count=len(roots),
missing_media_count=len(missing_keys),
)
+217
View File
@@ -0,0 +1,217 @@
from __future__ import annotations
import json
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable
try:
from .media_manifest import (
atomic_write_json,
find_location,
hash_file,
new_fallback_location,
selected_url,
)
from .media_refs import atomic_write_text, remote_markup
except ImportError: # pragma: no cover - script-mode import
if str(Path(__file__).resolve().parent) not in sys.path:
sys.path.insert(0, str(Path(__file__).resolve().parent))
from media_manifest import (
atomic_write_json,
find_location,
hash_file,
new_fallback_location,
selected_url,
)
from media_refs import atomic_write_text, remote_markup
@dataclass(frozen=True)
class FallbackResult:
url: str
reused: bool
def replace_selected_url(note_dir: Path, old_url: str, new_url: str) -> None:
if old_url == new_url:
return
old_markup = remote_markup(old_url)
new_markup = remote_markup(new_url)
hits: list[Path] = []
for path in note_dir.glob("*.md"):
if old_markup in path.read_text(encoding="utf-8"):
hits.append(path)
total = sum(path.read_text(encoding="utf-8").count(old_markup) for path in hits)
if total != 1:
if total == 0 and sum(
path.read_text(encoding="utf-8").count(new_markup)
for path in note_dir.glob("*.md")
) == 1:
return
raise ValueError("selected origin reference not found exactly once")
path = hits[0]
text = path.read_text(encoding="utf-8")
atomic_write_text(path, text.replace(old_markup, new_markup, 1))
def _group(manifest: dict[str, Any], media_id: str) -> list[dict[str, Any]]:
return [
item
for item in manifest.get("items") or []
if str(item.get("media_id") or "") == media_id
]
def _one(items: list[dict[str, Any]], role: str) -> dict[str, Any]:
matches = [item for item in items if item.get("role") == role]
if len(matches) != 1:
raise ValueError(f"expected one role={role}, found {len(matches)}")
return matches[0]
def _verified_local(item: dict[str, Any], asset_dir: Path) -> tuple[Path, str]:
local = find_location(item, "local")
if local is None or local.get("integrity") != "present":
raise ValueError("selected item has no present local location")
path = asset_dir / str(local.get("path") or "")
expected = str(local.get("sha256") or "")
if not path.is_file() or not expected or hash_file(path) != expected:
raise ValueError("selected local file failed hash verification")
return path, expected
def activate_fallback(
asset_dir: Path,
note_dir: Path,
*,
media_id: str,
role: str,
provider: str,
confirm_origin_unavailable: bool,
now: str,
upload: Callable[[Path], str],
lookup: Callable[[str, str], str | None] | None = None,
) -> FallbackResult:
if not confirm_origin_unavailable:
raise ValueError("origin-unavailable confirmation is required")
media_path = asset_dir / "media.json"
manifest = json.loads(media_path.read_text(encoding="utf-8"))
group = _group(manifest, media_id)
original = _one(group, "orig")
target = _one(group, role)
origin = find_location(original, "origin:x")
if origin is None:
raise ValueError("original item has no X origin location")
old_url = selected_url(next((item for item in group if item.get("embed")), original))
if old_url is None:
raise ValueError("media group has no selected HTTPS location")
local_path, content_hash = _verified_local(target, asset_dir)
origin["availability"] = "unavailable"
origin["confirmed_unavailable_at"] = now
fallback = next(
(
location
for location in target.get("locations") or []
if location.get("kind") == "fallback"
and location.get("provider") == provider
and location.get("sha256") == content_hash
),
None,
)
reused = fallback is not None
if fallback is None and lookup is not None:
reused_url = lookup(provider, content_hash)
if reused_url is not None:
fallback = new_fallback_location(
provider=provider,
url=reused_url,
content_hash=content_hash,
recorded_at=now,
uploaded_at=None,
)
target.setdefault("locations", []).append(fallback)
reused = True
if fallback is None:
url = upload(local_path)
fallback = new_fallback_location(
provider=provider,
url=url,
content_hash=content_hash,
recorded_at=now,
uploaded_at=now,
)
target.setdefault("locations", []).append(fallback)
atomic_write_json(media_path, manifest)
replace_selected_url(note_dir, old_url, str(fallback["url"]))
for item in group:
item["embed"] = item is target
target["publication"] = {
"selected_location_id": fallback["id"],
"selected_at": now,
"reason": "origin-unavailable",
}
atomic_write_json(media_path, manifest)
return FallbackResult(str(fallback["url"]), reused)
def find_existing_fallback(
assets_root: Path,
provider: str,
content_hash: str,
) -> str | None:
urls: set[str] = set()
for media_path in assets_root.rglob("media.json"):
data = json.loads(media_path.read_text(encoding="utf-8"))
if data.get("schema_version") != 2:
continue
for item in data.get("items") or []:
for location in item.get("locations") or []:
if (
location.get("kind") == "fallback"
and location.get("provider") == provider
and location.get("sha256") == content_hash
and str(location.get("url") or "").startswith("https://")
):
urls.add(str(location["url"]))
return sorted(urls)[0] if urls else None
def restore_origin(
asset_dir: Path,
note_dir: Path,
*,
media_id: str,
now: str,
) -> None:
media_path = asset_dir / "media.json"
manifest = json.loads(media_path.read_text(encoding="utf-8"))
group = _group(manifest, media_id)
original = _one(group, "orig")
origin = find_location(original, "origin:x")
if origin is None:
raise ValueError("original item has no X origin location")
visible = next((item for item in group if item.get("embed")), original)
old_url = selected_url(visible)
new_url = str(origin.get("url") or "")
if old_url is None or not new_url.startswith("https://"):
raise ValueError("media group has no restorable origin")
replace_selected_url(note_dir, old_url, new_url)
origin["availability"] = "available"
origin["checked_at"] = now
origin["check"] = {
"status": None,
"result": "available",
"detail": "manual restore-origin selection",
}
for item in group:
item["embed"] = item is original
original["publication"] = {
"selected_location_id": "origin:x",
"selected_at": now,
"reason": "manual",
}
atomic_write_json(media_path, manifest)
+50
View File
@@ -0,0 +1,50 @@
from __future__ import annotations
import json
from pathlib import Path
def load_frozen_ids(path: Path) -> set[str]:
if not path.is_file():
return set()
return {
line.strip()
for line in path.read_text(encoding="utf-8").splitlines()
if line.strip() and not line.lstrip().startswith("#")
}
def captured_ids(asset_dir: Path) -> set[str]:
found: set[str] = set()
media_path = asset_dir / "media.json"
if media_path.is_file():
media = json.loads(media_path.read_text(encoding="utf-8"))
root_id = str(media.get("root_post_id") or "")
if root_id:
found.add(root_id)
for item in media.get("items") or []:
post_id = str(item.get("post_id") or "")
if post_id:
found.add(post_id)
thread_path = asset_dir / "thread_data.json"
if thread_path.is_file():
thread = json.loads(thread_path.read_text(encoding="utf-8"))
root_id = str(thread.get("root_post_id") or "")
if root_id:
found.add(root_id)
for post in thread.get("posts") or []:
post_id = str(post.get("post_id") or "")
if post_id:
found.add(post_id)
return found
def frozen_match(asset_dir: Path, frozen_ids: set[str]) -> str | None:
matches = sorted(captured_ids(asset_dir) & frozen_ids)
return matches[0] if matches else None
def require_writable(asset_dir: Path, frozen_ids: set[str]) -> None:
match = frozen_match(asset_dir, frozen_ids)
if match is not None:
raise SystemExit(f"frozen: skipped ({match})")
+8 -193
View File
@@ -1,204 +1,19 @@
"""Upload curated stills to catbox and rewrite archive notes to HTTPS.
"""Deprecated.
Reads userhash from vault secrets/credentials.toml [catbox]. Never prints it.
python lift_catbox.py --thread ASSETS_THREAD --notes ARCHIVE_THREAD --orig
--orig marks every on-disk orig row publish=true then uploads those.
Default without --orig: only rows already publish=true.
After upload, any note token that is the local filename (backticked or
bare) or a previous url for that row becomes ![](https://files.catbox.moe/…).
Catbox rate-limits the userhash per minute. Algorithm:
- One attempt per upload. No curl retries. A failure is a failure —
the user retries the whole lift later (the script persists progress
after each success, so partial work survives).
- 60-second sleep between consecutive uploads in the same run.
- 200 KB/s upload cap per curl.
- On empty/non-URL body: sleep 120s before raising, so a manual
retry minutes later doesn't arrive during catbox's cooldown.
- media.json is rewritten after each successful upload (atomic),
not at the end of the run.
- Errors are sanitized to redact the userhash before printing.
Bulk lift has been retired. Use `tw.py fallback --id <id> --media-id <media-id> --role <role>
--confirm-origin-unavailable` after confirming the origin is unavailable.
"""
from __future__ import annotations
import argparse
import json
import re
import subprocess
import sys
import time
import tomllib
from pathlib import Path
from paths import VAULT as VAULT_ROOT
API = "https://catbox.moe/user/api.php"
MAX_TIME = 180
THROTTLE_SECS = 60.0
LIMIT_RATE = "200k"
EMPTY_BACKOFF = 120.0
def _redact_cmd(cmd: list[str]) -> str:
out: list[str] = []
for tok in cmd:
if tok.startswith("userhash="):
out.append("userhash=<redacted>")
else:
out.append(tok)
return " ".join(out)
def load_userhash(vault: Path) -> str:
path = vault / "secrets" / "credentials.toml"
if not path.is_file():
raise SystemExit(f"missing {path}")
text = path.read_text(encoding="utf-8")
try:
data = tomllib.loads(text)
h = str((data.get("catbox") or {}).get("userhash") or "").strip()
except tomllib.TOMLDecodeError:
h = ""
in_catbox = False
for raw in text.splitlines():
line = raw.strip()
if line.startswith("[") and line.endswith("]"):
in_catbox = line[1:-1].strip().lower() == "catbox"
continue
if in_catbox and line.lower().startswith("userhash"):
_, _, rest = line.partition("=")
h = rest.strip().strip("\"'")
break
if not h:
raise SystemExit("credentials.toml [catbox] userhash is empty")
return h
def upload(path: Path, userhash: str) -> str:
cmd = [
"curl.exe",
"-sS",
"--max-time",
str(MAX_TIME),
"--limit-rate",
LIMIT_RATE,
"-F",
"reqtype=fileupload",
"-F",
f"userhash={userhash}",
"-F",
f"fileToUpload=@{path}",
API,
]
try:
raw = subprocess.check_output(cmd, text=True, stderr=subprocess.STDOUT)
except subprocess.CalledProcessError as exc:
time.sleep(EMPTY_BACKOFF)
raise SystemExit(
f"catbox upload failed for {path.name} "
f"(curl exit {exc.returncode}); waited {EMPTY_BACKOFF:.0f}s before exit. "
f"cmd: {_redact_cmd(cmd)}"
) from None
url = raw.strip()
if not url.startswith("https://"):
time.sleep(EMPTY_BACKOFF)
raise SystemExit(
f"catbox refused {path.name} (empty/non-URL body); "
f"waited {EMPTY_BACKOFF:.0f}s before exit. body[:200]={url[:200]!r}"
)
return url
def rewrite_notes(notes_dir: Path, old_tokens: list[str], url: str) -> int:
embed = f"![]({url})"
changed = 0
for path in notes_dir.glob("*.md"):
text = path.read_text(encoding="utf-8")
new = text
for token in old_tokens:
if not token:
continue
new = new.replace(f"`{token}`", embed)
new = new.replace(f"![]({token})", embed)
new = re.sub(r"^Media \(not lifted\):\s*", "", new, flags=re.M)
while True:
split = re.sub(
r"(!\[[^\]]*\]\([^)]+\))\s+(!\[[^\]]*\]\([^)]+\))",
r"\1\n\n\2",
new,
)
if split == new:
break
new = split
if new != text:
path.write_text(new, encoding="utf-8", newline="\n")
changed += 1
print(f"rewrote {path.name}")
return changed
def persist(media_path: Path, data: dict) -> None:
media_path.write_text(
json.dumps(data, indent=2) + "\n",
encoding="utf-8",
newline="\n",
def main(argv: list[str] | None = None) -> int: # pragma: no cover - deprecated entry
del argv
sys.stderr.write(
"lift_catbox.py is retired; use tw.py fallback with --confirm-origin-unavailable\n"
)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--vault", type=Path, default=VAULT_ROOT)
parser.add_argument("--thread", type=Path, required=True)
parser.add_argument("--notes", type=Path, required=True)
parser.add_argument(
"--orig",
action="store_true",
help="publish+upload every orig file that exists on disk",
)
parser.add_argument("--dry-run", action="store_true")
args = parser.parse_args(argv)
media_path = args.thread / "media.json"
data = json.loads(media_path.read_text(encoding="utf-8"))
items = data.setdefault("items", [])
if args.orig:
for item in items:
if item.get("role") == "orig":
item["publish"] = True
if "embed" not in item:
item["embed"] = True
userhash = None if args.dry_run else load_userhash(args.vault)
uploaded = 0
publishable = [it for it in items if it.get("publish")]
for i, item in enumerate(publishable):
if i > 0 and not args.dry_run:
print(f"throttling {THROTTLE_SECS:.0f}s before next upload…")
time.sleep(THROTTLE_SECS)
name = str(item.get("filename") or "")
src = args.thread / name
if not src.is_file():
print(f"skip missing {name}")
continue
old = [name, str(item.get("url") or "")]
if args.dry_run:
print(f"would upload {name}")
continue
assert userhash is not None
url = upload(src, userhash)
item["url"] = url
uploaded += 1
print(f"uploaded {name} -> {url}")
rewrite_notes(args.notes, old, url)
persist(media_path, data)
print(f"persisted media.json ({uploaded} done so far)")
print(f"uploaded {uploaded}")
return 0
return 2
if __name__ == "__main__":
+138
View File
@@ -0,0 +1,138 @@
from __future__ import annotations
import json
import sys
from collections import Counter
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
try:
from .frozen import frozen_match
from .media_manifest import (
find_location,
hash_file,
inventory_digest,
item_key,
payload_inventory,
selected_url,
validate_manifest,
)
from .media_refs import remote_markup
except ImportError: # pragma: no cover - script-mode import
if str(Path(__file__).resolve().parent) not in sys.path:
sys.path.insert(0, str(Path(__file__).resolve().parent))
from frozen import frozen_match
from media_manifest import (
find_location,
hash_file,
inventory_digest,
item_key,
payload_inventory,
selected_url,
validate_manifest,
)
from media_refs import remote_markup
@dataclass(frozen=True)
class AuditReport:
frozen: bool
issues: tuple[str, ...]
def audit_local_item(item: dict[str, Any], asset_dir: Path) -> str | None:
local = find_location(item, "local")
if local is None:
return f"{'/'.join(item_key(item))} missing local location"
path = asset_dir / str(local.get("path") or "")
if not path.is_file():
return f"{'/'.join(item_key(item))} local missing"
if path.stat().st_size != local.get("bytes") or hash_file(path) != local.get("sha256"):
return f"{'/'.join(item_key(item))} local mismatch"
return None
def classify_origin_response(status: int | None, detail: str, checked_at: str) -> dict[str, Any]:
available = status is not None and 200 <= status < 400
return {
"checked_at": checked_at,
"status": status,
"result": "available" if available else "error",
"detail": detail[:200],
"confirms_unavailable": False,
}
def check_origin_url(
url: str,
checked_at: str,
opener: Any = urlopen,
) -> dict[str, Any]:
request = Request(url, method="HEAD", headers={"User-Agent": "Threadwell-media-audit/1"})
try:
with opener(request, timeout=30) as response:
return classify_origin_response(
int(response.status),
"HEAD completed",
checked_at,
)
except HTTPError as exc:
return classify_origin_response(exc.code, f"HTTP {exc.code}", checked_at)
except (URLError, TimeoutError, OSError) as exc:
return classify_origin_response(None, type(exc).__name__, checked_at)
def record_origin_check(item: dict[str, Any], outcome: dict[str, Any]) -> None:
origin = find_location(item, "origin:x")
if origin is None:
raise ValueError("item has no origin:x location")
origin["checked_at"] = outcome["checked_at"]
origin["check"] = {
"status": outcome["status"],
"result": outcome["result"],
"detail": outcome["detail"],
}
if outcome["result"] == "available":
origin["availability"] = "available"
def audit_thread(
asset_dir: Path,
note_dir: Path,
frozen_ids: set[str],
) -> AuditReport:
match = frozen_match(asset_dir, frozen_ids)
if match is not None:
return AuditReport(True, (f"frozen: skipped ({match})",))
manifest = json.loads((asset_dir / "media.json").read_text(encoding="utf-8"))
issues = list(validate_manifest(manifest))
for item in manifest.get("items") or []:
if find_location(item, "local") is not None:
issue = audit_local_item(item, asset_dir)
if issue is not None:
issues.append(issue)
expected = Counter(
remote_markup(url)
for item in manifest.get("items") or []
if item.get("embed") and (url := selected_url(item)) is not None
)
note_text = "\n".join(
path.read_text(encoding="utf-8")
for path in sorted(note_dir.glob("*.md"))
)
for markup, count in expected.items():
actual = note_text.count(markup)
if actual != count:
issues.append(
f"selected reference count mismatch: {markup} expected={count} actual={actual}"
)
current_digest = inventory_digest(payload_inventory(asset_dir, manifest))
for mirror in manifest.get("mirrors") or []:
if mirror.get("state") == "synced" and mirror.get("inventory_digest") != current_digest:
issues.append(f"mirror stale: {mirror.get('destination_id')}")
return AuditReport(False, tuple(sorted(set(issues))))
+60 -110
View File
@@ -1,83 +1,32 @@
"""Select which still a note cites, and optionally attach OCR under it.
"""Attach OCR transcripts under the cited origin media. Local variants are
not publication locations; visual selection is driven by the canonical
manifest's `embed` flag.
Notes currently say:
Media (not lifted): `1736…_GBgHT…_orig.png`
After lift they will say:
![](https://files.catbox.moe/xxxx.png)
This script only rewrites those two shapes. It does not upload.
python media_embed.py --thread <assets-thread-dir> --notes <archive-thread-dir> --media-id GBgHT_2WIAAKuNq --show crt
python media_embed.py --thread <assets-thread-dir> --notes <archive-thread-dir> --media-id GBgHT_2WIAAKuNq --attach-ocr
python media_embed.py --thread <assets-thread-dir> --notes <archive-thread-dir> --media-id <id> --attach-ocr
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
IMAGE_MD = re.compile(r"!\[([^\]]*)\]\(([^)]+)\)")
LIFTED_LINE = re.compile(r"^Media \(not lifted\): (.+)$", re.M)
try:
from .media_manifest import atomic_write_json, find_location, selected_url, validate_manifest
except ImportError: # pragma: no cover - script-mode import
if str(Path(__file__).resolve().parent) not in sys.path:
sys.path.insert(0, str(Path(__file__).resolve().parent))
from media_manifest import atomic_write_json, find_location, selected_url, validate_manifest
def load_media(path: Path) -> dict:
return json.loads(path.read_text(encoding="utf-8"))
def save_media(path: Path, data: dict) -> None:
path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
def group_items(items: list[dict]) -> dict[tuple[str, str], dict[str, dict]]:
groups: dict[tuple[str, str], dict[str, dict]] = {}
for item in items:
key = (str(item.get("post_id") or ""), str(item.get("media_id") or ""))
groups.setdefault(key, {})[str(item.get("role") or "")] = item
return groups
def set_embed(items: list[dict], media_id: str, show: str) -> None:
found = False
for item in items:
if str(item.get("media_id") or "") != media_id:
continue
if item.get("role") in ("orig", "crt", "crt_outline"):
item["embed"] = item.get("role") == show
found = True
if not found:
raise SystemExit(f"no orig/crt row for media_id {media_id}")
def token_for(item: dict) -> str:
url = item.get("url")
if url:
return str(url)
return str(item.get("filename") or "")
def rewrite_notes(notes_dir: Path, old: str, new: str) -> int:
if not old or old == new:
return 0
changed = 0
for path in notes_dir.glob("*.md"):
text = path.read_text(encoding="utf-8")
if old not in text:
continue
path.write_text(text.replace(old, new), encoding="utf-8")
changed += 1
print(f"rewrote {path.name}")
return changed
OCR_DETAILS = "<summary>Text from still</summary>"
def ocr_block(text: str) -> str:
body = text.strip("\n")
return (
"<details>\n"
"<summary>Text from still</summary>\n"
f"{OCR_DETAILS}\n"
"\n"
"```\n"
f"{body}\n"
@@ -94,23 +43,20 @@ def attach_ocr(notes_dir: Path, image_token: str, ocr_text: str) -> int:
text = path.read_text(encoding="utf-8")
if image_token not in text:
continue
if "<summary>Text from still</summary>" in text and image_token in text:
if OCR_DETAILS in text and image_token in text:
print(f"ocr already near token in {path.name}")
continue
# After the line that cites the still.
lines = text.splitlines(keepends=True)
out: list[str] = []
i = 0
inserted = False
while i < len(lines):
out.append(lines[i])
if image_token in lines[i] and not inserted:
if not lines[i].endswith("\n"):
out[-1] = lines[i] + "\n"
for i, line in enumerate(lines):
out.append(line)
if image_token in line and not inserted:
if not line.endswith("\n"):
out[-1] = line + "\n"
out.append("\n")
out.append(block + "\n")
inserted = True
i += 1
if inserted:
path.write_text("".join(out), encoding="utf-8")
changed += 1
@@ -123,50 +69,54 @@ def main(argv: list[str] | None = None) -> int:
parser.add_argument("--thread", type=Path, required=True, help="assets/threads/.../ folder")
parser.add_argument("--notes", type=Path, required=True, help="archive/threads/.../ folder")
parser.add_argument("--media-id", required=True)
parser.add_argument("--show", choices=("orig", "crt"), default=None)
parser.add_argument("--attach-ocr", action="store_true")
args = parser.parse_args(argv)
media_path = args.thread / "media.json"
data = load_media(media_path)
items = data["items"]
groups = group_items(items)
match = None
pair = None
for key, roles in groups.items():
if key[1] == args.media_id:
match = key
pair = roles
break
if pair is None or match is None:
raise SystemExit(f"media_id {args.media_id} not in {media_path}")
if args.show:
if args.show not in pair:
raise SystemExit(f"no role={args.show} for {args.media_id}")
old_item = next((pair[r] for r in ("crt", "orig") if pair.get(r, {}).get("embed")), None)
if old_item is None:
old_item = pair.get("orig") or pair.get("crt")
set_embed(items, args.media_id, args.show)
save_media(media_path, data)
new_item = pair[args.show]
rewrite_notes(args.notes, token_for(old_item), token_for(new_item))
print(f"embed {args.media_id} -> {args.show} ({token_for(new_item)})")
raise SystemExit(
"local variants are not publishable locations; after confirming an "
"origin failure, use tw.py fallback with the desired --role"
)
if not args.attach_ocr:
parser.print_help()
return 2
if args.attach_ocr:
ocr = pair.get("ocr")
if ocr is None:
raise SystemExit(f"no role=ocr for {args.media_id}; run ocr_pass.py first")
ocr_path = args.thread / str(ocr["filename"])
if not ocr_path.is_file():
raise SystemExit(f"missing {ocr_path}")
shown = next((pair[r] for r in ("crt", "orig") if pair.get(r, {}).get("embed")), None)
if shown is None:
shown = pair.get("orig") or pair.get("crt")
attach_ocr(args.notes, token_for(shown), ocr_path.read_text(encoding="utf-8"))
media_path = args.thread / "media.json"
data = json.loads(media_path.read_text(encoding="utf-8"))
issues = validate_manifest(data)
if issues:
raise SystemExit(f"manifest invalid: {issues[0]}")
target = None
for item in data.get("items") or []:
if (
str(item.get("media_id") or "") == args.media_id
and str(item.get("role") or "") == "ocr"
):
target = item
break
if target is None:
raise SystemExit(f"no role=ocr for {args.media_id}; run ocr_pass.py first")
ocr_path = args.thread / str(target.get("filename") or "")
if not ocr_path.is_file():
raise SystemExit(f"missing {ocr_path}")
visible = None
for item in data.get("items") or []:
if str(item.get("media_id") or "") != args.media_id:
continue
if item.get("embed") and selected_url(item):
visible = item
break
if visible is None:
raise SystemExit("media group has no embedded origin")
image_token = str(selected_url(visible) or "")
if not image_token:
raise SystemExit("media group has no selected HTTPS location")
attach_ocr(args.notes, image_token, ocr_path.read_text(encoding="utf-8"))
atomic_write_json(media_path, data)
return 0
if __name__ == "__main__":
raise SystemExit(main())
raise SystemExit(main())
+337
View File
@@ -0,0 +1,337 @@
from __future__ import annotations
import copy
import json
import mimetypes
import os
from datetime import datetime, timezone
from hashlib import sha256
from pathlib import Path
from typing import Any
SCHEMA_VERSION = 2
VISUAL_ROLES = {"orig", "crt", "crt_outline", "denoise"}
def utc_now() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
def hash_file(path: Path) -> str:
digest = sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def atomic_write_json(path: Path, data: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temp = path.with_name(f".{path.name}.{os.getpid()}.tmp")
try:
with temp.open("w", encoding="utf-8", newline="\n") as handle:
json.dump(data, handle, indent=2, ensure_ascii=False)
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
os.replace(temp, path)
finally:
if temp.exists():
temp.unlink()
def item_key(item: dict[str, Any]) -> tuple[str, str, str]:
return (
str(item.get("post_id") or ""),
str(item.get("media_id") or ""),
str(item.get("role") or ""),
)
def find_location(item: dict[str, Any], location_id: str) -> dict[str, Any] | None:
for location in item.get("locations") or []:
if str(location.get("id") or "") == location_id:
return location
return None
def location_of_kind(item: dict[str, Any], kind: str) -> dict[str, Any] | None:
for location in item.get("locations") or []:
if location.get("kind") == kind:
return location
return None
def selected_location(item: dict[str, Any]) -> dict[str, Any] | None:
publication = item.get("publication")
if not isinstance(publication, dict):
return None
return find_location(item, str(publication.get("selected_location_id") or ""))
def selected_url(item: dict[str, Any]) -> str | None:
location = selected_location(item)
if not location or location.get("kind") not in {"origin", "fallback"}:
return None
value = str(location.get("url") or "")
return value if value.startswith("https://") else None
def _media_type(filename: str) -> str:
guessed, _encoding = mimetypes.guess_type(filename)
return guessed or "application/octet-stream"
def _local_location(filename: str, local_path: Path, now: str) -> dict[str, Any]:
present = local_path.is_file()
return {
"id": "local",
"kind": "local",
"path": filename,
"sha256": hash_file(local_path) if present else None,
"bytes": local_path.stat().st_size if present else None,
"media_type": _media_type(filename),
"integrity": "present" if present else "missing",
"verified_at": now,
}
def new_original_item(
*,
post_id: str,
media_id: str,
handle: str,
origin_url: str,
filename: str,
local_path: Path,
now: str,
) -> dict[str, Any]:
return {
"post_id": post_id,
"media_id": media_id,
"handle": handle,
"role": "orig",
"derived_from": None,
"embed": True,
"locations": [
{
"id": "origin:x",
"kind": "origin",
"provider": "x",
"url": origin_url,
"availability": "unknown",
"checked_at": None,
"check": None,
"confirmed_unavailable_at": None,
},
_local_location(filename, local_path, now),
],
"publication": {
"selected_location_id": "origin:x",
"selected_at": now,
"reason": "default",
},
}
def merge_item(existing: dict[str, Any], fresh: dict[str, Any]) -> dict[str, Any]:
if item_key(existing) != item_key(fresh):
raise ValueError("cannot merge different media items")
merged = copy.deepcopy(existing)
merged["handle"] = fresh["handle"]
existing_origin = location_of_kind(existing, "origin")
fresh_origin = location_of_kind(fresh, "origin")
if (
existing_origin is not None
and fresh_origin is not None
and existing_origin.get("url") != fresh_origin.get("url")
):
raise ValueError(f"origin URL changed for {item_key(existing)!r}")
fresh_local = location_of_kind(fresh, "local")
locations = [
copy.deepcopy(location)
for location in merged.get("locations") or []
if location.get("kind") != "local"
]
if fresh_local is not None:
locations.append(copy.deepcopy(fresh_local))
if location_of_kind(merged, "origin") is None:
fresh_origin = location_of_kind(fresh, "origin")
if fresh_origin is not None:
locations.insert(0, copy.deepcopy(fresh_origin))
merged["locations"] = locations
if merged.get("publication") is None:
merged["publication"] = copy.deepcopy(fresh.get("publication"))
return merged
def merge_manifest_items(
existing_items: list[dict[str, Any]],
fresh_items: list[dict[str, Any]],
) -> list[dict[str, Any]]:
existing_by_key = {item_key(item): item for item in existing_items}
fresh_keys: set[tuple[str, str, str]] = set()
merged: list[dict[str, Any]] = []
for fresh in fresh_items:
key = item_key(fresh)
fresh_keys.add(key)
previous = existing_by_key.get(key)
merged.append(merge_item(previous, fresh) if previous is not None else fresh)
for previous in existing_items:
if item_key(previous) not in fresh_keys:
merged.append(copy.deepcopy(previous))
return merged
def fallback_location_id(provider: str, url: str) -> str:
digest = sha256(f"{provider}\0{url}".encode("utf-8")).hexdigest()[:16]
return f"fallback:{provider}:{digest}"
def new_fallback_location(
*,
provider: str,
url: str,
content_hash: str | None,
recorded_at: str,
uploaded_at: str | None,
) -> dict[str, Any]:
return {
"id": fallback_location_id(provider, url),
"kind": "fallback",
"provider": provider,
"url": url,
"sha256": content_hash,
"recorded_at": recorded_at,
"uploaded_at": uploaded_at,
"availability": "unknown",
"checked_at": None,
"check": None,
}
def new_derived_item(
*,
legacy: dict[str, Any],
asset_dir: Path,
now: str,
) -> dict[str, Any]:
filename = str(legacy.get("filename") or "")
post_id = str(legacy.get("post_id") or "")
media_id = str(legacy.get("media_id") or "")
role = str(legacy.get("role") or "")
return {
"post_id": post_id,
"media_id": media_id,
"handle": str(legacy.get("handle") or ""),
"role": role,
"derived_from": {
"post_id": post_id,
"media_id": media_id,
"role": "orig",
},
"embed": False,
"locations": [_local_location(filename, asset_dir / filename, now)],
"publication": None,
}
def upsert_derived_item(
manifest: dict[str, Any],
*,
post_id: str,
media_id: str,
handle: str,
role: str,
filename: str,
asset_dir: Path,
now: str,
) -> dict[str, Any]:
legacy_shape = {
"post_id": post_id,
"media_id": media_id,
"handle": handle,
"role": role,
"filename": filename,
}
fresh = new_derived_item(legacy=legacy_shape, asset_dir=asset_dir, now=now)
key = item_key(fresh)
items = list(manifest.get("items") or [])
for index, existing in enumerate(items):
if item_key(existing) == key:
fresh["locations"] = [
location
for location in existing.get("locations") or []
if location.get("kind") != "local"
] + fresh["locations"]
fresh["embed"] = bool(existing.get("embed"))
fresh["publication"] = copy.deepcopy(existing.get("publication"))
items[index] = fresh
manifest["items"] = items
return fresh
items.append(fresh)
manifest["items"] = items
return fresh
def canonical_manifest_bytes(data: dict[str, Any]) -> bytes:
stable = copy.deepcopy(data)
stable["mirrors"] = []
return (
json.dumps(stable, sort_keys=True, ensure_ascii=False, separators=(",", ":"))
+ "\n"
).encode("utf-8")
def payload_inventory(asset_dir: Path, manifest: dict[str, Any]) -> dict[str, str]:
inventory: dict[str, str] = {
"media.json#canonical": sha256(canonical_manifest_bytes(manifest)).hexdigest()
}
for path in sorted(asset_dir.iterdir(), key=lambda candidate: candidate.name):
if path.is_file() and path.name != "media.json":
inventory[path.name] = hash_file(path)
return inventory
def inventory_digest(inventory: dict[str, str]) -> str:
encoded = json.dumps(inventory, sort_keys=True, separators=(",", ":")).encode("utf-8")
return sha256(encoded).hexdigest()
def validate_manifest(data: dict[str, Any]) -> list[str]:
issues: list[str] = []
if data.get("schema_version") != SCHEMA_VERSION:
issues.append(f"schema_version must be {SCHEMA_VERSION}")
if not str(data.get("root_post_id") or ""):
issues.append("root_post_id is empty")
seen: set[tuple[str, str, str]] = set()
for index, item in enumerate(data.get("items") or []):
key = item_key(item)
if not all(key):
issues.append(f"items[{index}] has incomplete key {key!r}")
if key in seen:
issues.append(f"items[{index}] duplicates key {key!r}")
seen.add(key)
location_ids: set[str] = set()
for location in item.get("locations") or []:
location_id = str(location.get("id") or "")
if not location_id:
issues.append(f"items[{index}] has empty location id")
elif location_id in location_ids:
issues.append(f"items[{index}] duplicates location {location_id}")
location_ids.add(location_id)
publication = item.get("publication")
if publication is None:
if item.get("role") in VISUAL_ROLES and item.get("embed"):
issues.append(f"items[{index}] embeds without publication")
continue
selected_id = str(publication.get("selected_location_id") or "")
location = find_location(item, selected_id)
if location is None:
issues.append(f"items[{index}] selects missing location {selected_id}")
elif location.get("kind") not in {"origin", "fallback"}:
issues.append(f"items[{index}] selects non-HTTPS location {selected_id}")
elif not str(location.get("url") or "").startswith("https://"):
issues.append(f"items[{index}] selects non-HTTPS location {selected_id}")
return issues
+26 -18
View File
@@ -9,6 +9,8 @@ from __future__ import annotations
import argparse
import json
import sys
from datetime import datetime, timezone
from pathlib import Path
ROLE_SUFFIX = {
@@ -46,8 +48,19 @@ def main(argv: list[str] | None = None) -> int:
args = parser.parse_args(argv)
media_path = args.thread / "media.json"
data = json.loads(media_path.read_text(encoding="utf-8"))
if data.get("schema_version") != 2:
raise SystemExit(
"legacy media.json requires: tw.py migrate-media --id <id> --apply"
)
try:
from media_manifest import atomic_write_json, upsert_derived_item
except ImportError: # pragma: no cover - script-mode import
from .media_manifest import atomic_write_json, upsert_derived_item
now = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
items = list(data.get("items") or [])
have = {(i.get("filename"), i.get("role")) for i in items}
have = {(str(i.get("filename") or ""), str(i.get("role") or "")) for i in items}
handles = {str(i.get("media_id") or ""): str(i.get("handle") or "") for i in items}
added = 0
for path in sorted(args.thread.iterdir()):
role = role_of(path.name)
@@ -56,25 +69,20 @@ def main(argv: list[str] | None = None) -> int:
if (path.name, role) in have:
continue
post_id, media_id = parse_ids(path.name)
handle = ""
for item in items:
if item.get("media_id") == media_id:
handle = str(item.get("handle") or "")
break
items.append({
"post_id": post_id,
"media_id": media_id,
"handle": handle,
"role": role,
"filename": path.name,
"publish": False,
"url": None,
"embed": False,
})
handle = handles.get(media_id, "")
upsert_derived_item(
data,
post_id=post_id,
media_id=media_id,
handle=handle,
role=role,
filename=path.name,
asset_dir=args.thread,
now=now,
)
added += 1
print(f"restore {path.name} role={role}")
data["items"] = items
media_path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
atomic_write_json(media_path, data)
print(f"added {added} -> {media_path}")
return 0
+315
View File
@@ -0,0 +1,315 @@
from __future__ import annotations
import copy
import json
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from urllib.parse import unquote, urlparse
try:
from .frozen import frozen_match
from .media_manifest import (
SCHEMA_VERSION,
atomic_write_json,
find_location,
hash_file,
new_derived_item,
new_fallback_location,
new_original_item,
validate_manifest,
)
from .media_refs import apply_rewrite_plan, atomic_write_text, plan_thread_rewrites
except ImportError: # pragma: no cover - script-mode import
if str(Path(__file__).resolve().parent) not in sys.path:
sys.path.insert(0, str(Path(__file__).resolve().parent))
from frozen import frozen_match
from media_manifest import (
SCHEMA_VERSION,
atomic_write_json,
find_location,
hash_file,
new_derived_item,
new_fallback_location,
new_original_item,
validate_manifest,
)
from media_refs import apply_rewrite_plan, atomic_write_text, plan_thread_rewrites
@dataclass(frozen=True)
class MigrationResult:
state: str
changed: bool
issues: tuple[str, ...]
item_count: int
def media_id_from_url(url: str, index: int) -> str:
segment = Path(unquote(urlparse(url).path)).name
return segment if segment else f"m{index}"
def source_urls(thread_data: dict[str, Any]) -> dict[tuple[str, str], str]:
result: dict[tuple[str, str], str] = {}
for post in thread_data.get("posts") or []:
post_id = str(post.get("post_id") or "")
for index, url in enumerate(post.get("media_urls") or [], start=1):
key = (post_id, media_id_from_url(str(url), index))
if key in result and result[key] != url:
raise ValueError(f"duplicate source mapping for {key!r}")
result[key] = str(url)
return result
def reference_maps(
legacy: dict[str, Any], canonical: dict[str, Any]
) -> tuple[dict[str, str], dict[str, list[str]]]:
canonical_by_key = {
(
str(item.get("post_id") or ""),
str(item.get("media_id") or ""),
str(item.get("role") or ""),
): item
for item in canonical.get("items") or []
}
filename_origins: dict[str, str] = {}
fallback_origins: dict[str, list[str]] = {}
for old in legacy.get("items") or []:
if old.get("role") != "orig":
continue
key = (
str(old.get("post_id") or ""),
str(old.get("media_id") or ""),
"orig",
)
item = canonical_by_key[key]
origin = next(
location
for location in item["locations"]
if location.get("kind") == "origin"
)["url"]
filename_origins[str(old.get("filename") or "")] = origin
old_url = str(old.get("url") or "")
if old_url.startswith("https://"):
fallback_origins.setdefault(old_url, []).append(origin)
return filename_origins, fallback_origins
def corpus_inventory(
assets_root: Path,
note_root: Path,
frozen_ids: set[str],
*,
now: str,
) -> dict[str, object]:
media_paths = sorted(assets_root.rglob("media.json"))
counts: dict[str, int] = {
"input_items": 0,
"migratable": 0,
"frozen_skipped": 0,
"missing_origin_mapping": 0,
"missing_local_file": 0,
"ambiguous_note_reference": 0,
"invalid_legacy_row": 0,
}
threads: list[dict[str, object]] = []
frozen_hashes: dict[str, str] = {}
for media_path in media_paths:
asset_dir = media_path.parent
relative = asset_dir.relative_to(assets_root).as_posix()
match = frozen_match(asset_dir, frozen_ids)
legacy = json.loads(media_path.read_text(encoding="utf-8"))
is_legacy = legacy.get("schema_version") != SCHEMA_VERSION
legacy_items = list(legacy.get("items") or [])
unique_legacy = {
(
str(item.get("post_id") or ""),
str(item.get("media_id") or ""),
str(item.get("role") or ""),
)
for item in legacy_items
}
counts["input_items"] += len(unique_legacy)
for path in sorted(asset_dir.iterdir()):
if path.is_file() and path.name != "media.json":
key = f"{relative}/{path.name}"
frozen_hashes[key] = hash_file(path)
for archive in sorted((note_root / relative).glob("*.md")) if (note_root / relative).is_dir() else []:
key = f"{relative}/{archive.name}"
frozen_hashes[key] = hash_file(archive)
if match is not None:
counts["frozen_skipped"] += len(unique_legacy)
threads.append({
"relative": relative,
"state": "frozen_skipped",
"frozen_id": match,
"item_count": len(unique_legacy),
})
continue
thread_data_path = asset_dir / "thread_data.json"
if not thread_data_path.is_file():
counts["missing_origin_mapping"] += len(unique_legacy)
threads.append({
"relative": relative,
"state": "missing_origin_mapping",
"item_count": len(unique_legacy),
"issues": ["missing thread_data.json"],
})
continue
thread_data = json.loads(thread_data_path.read_text(encoding="utf-8"))
try:
canonical, issues = canonical_from_legacy(legacy, thread_data, asset_dir, now)
except Exception as exc:
counts["invalid_legacy_row"] += len(unique_legacy)
threads.append({
"relative": relative,
"state": "invalid_legacy_row",
"item_count": len(unique_legacy),
"issues": [str(exc)],
})
continue
if issues:
counts["invalid_legacy_row"] += len(unique_legacy)
threads.append({
"relative": relative,
"state": "invalid_legacy_row",
"item_count": len(unique_legacy),
"issues": list(issues),
})
continue
counts["migratable"] += len(canonical["items"])
thread_entry: dict[str, object] = {
"relative": relative,
"state": "migratable" if is_legacy else "current",
"item_count": len(canonical["items"]),
}
threads.append(thread_entry)
expected_total = (
counts["migratable"]
+ counts["frozen_skipped"]
+ counts["missing_origin_mapping"]
+ counts["missing_local_file"]
+ counts["ambiguous_note_reference"]
+ counts["invalid_legacy_row"]
)
if counts["input_items"] != expected_total:
raise SystemExit("corpus accounting mismatch")
return {
"schema_version": 1,
"generated_at": now,
"counts": counts,
"threads": threads,
"frozen_hashes": frozen_hashes,
}
def canonical_from_legacy(
legacy: dict[str, Any],
thread_data: dict[str, Any],
asset_dir: Path,
now: str,
) -> tuple[dict[str, Any], tuple[str, ...]]:
sources = source_urls(thread_data)
items: list[dict[str, Any]] = []
issues: list[str] = []
seen: set[tuple[str, str, str]] = set()
for old in legacy.get("items") or []:
role = str(old.get("role") or "")
post_id = str(old.get("post_id") or "")
media_id = str(old.get("media_id") or "")
filename = str(old.get("filename") or "")
key = (post_id, media_id, role)
if key in seen:
continue
seen.add(key)
if role == "orig":
origin_url = sources.get((post_id, media_id))
if origin_url is None:
issues.append(f"missing origin mapping: {post_id}/{media_id}")
continue
item = new_original_item(
post_id=post_id,
media_id=media_id,
handle=str(old.get("handle") or ""),
origin_url=origin_url,
filename=filename,
local_path=asset_dir / filename,
now=now,
)
old_url = str(old.get("url") or "")
if old_url.startswith("https://"):
local = find_location(item, "local")
item["locations"].append(
new_fallback_location(
provider="catbox",
url=old_url,
content_hash=str(local.get("sha256") or "") or None,
recorded_at=now,
uploaded_at=None,
)
)
items.append(item)
elif role in {"crt", "crt_outline", "denoise", "ocr"}:
item = new_derived_item(legacy=old, asset_dir=asset_dir, now=now)
old_url = str(old.get("url") or "")
if old_url.startswith("https://"):
local = find_location(item, "local")
item["locations"].append(
new_fallback_location(
provider="catbox",
url=old_url,
content_hash=str(local.get("sha256") or "") or None,
recorded_at=now,
uploaded_at=None,
)
)
items.append(item)
else:
issues.append(f"invalid legacy role: {post_id}/{media_id}/{role}")
manifest = {
"schema_version": SCHEMA_VERSION,
"root_post_id": str(legacy.get("root_post_id") or ""),
"items": items,
"mirrors": copy.deepcopy(legacy.get("mirrors") or []),
}
issues.extend(validate_manifest(manifest))
return manifest, tuple(issues)
def migrate_legacy_thread(
asset_dir: Path,
note_dir: Path,
*,
now: str,
apply: bool,
) -> MigrationResult:
media_path = asset_dir / "media.json"
legacy = json.loads(media_path.read_text(encoding="utf-8"))
if legacy.get("schema_version") == SCHEMA_VERSION:
return MigrationResult("current", False, (), len(legacy.get("items") or []))
thread_data = json.loads((asset_dir / "thread_data.json").read_text(encoding="utf-8"))
canonical, issues = canonical_from_legacy(legacy, thread_data, asset_dir, now)
if issues:
return MigrationResult("blocked", False, issues, len(legacy.get("items") or []))
if apply:
filename_origins, fallback_origins = reference_maps(legacy, canonical)
plan = plan_thread_rewrites(
note_dir,
filename_origins=filename_origins,
fallback_origins=fallback_origins,
)
if plan.issues:
return MigrationResult("blocked", False, plan.issues, len(legacy.get("items") or []))
note_backup: dict[Path, str] = {}
try:
for rewrite in plan.files:
note_backup[rewrite.path] = rewrite.before
atomic_write_json(media_path, canonical)
apply_rewrite_plan(plan)
except Exception:
for path, before in note_backup.items():
atomic_write_text(path, before)
raise
return MigrationResult("migratable", apply, (), len(legacy.get("items") or []))
+125
View File
@@ -0,0 +1,125 @@
from __future__ import annotations
import os
import re
from collections import Counter, deque
from dataclasses import dataclass
from pathlib import Path
MEDIA_LINE = re.compile(r"(?m)^Media \(not lifted\):\s*(?P<body>.+?)\s*$")
BACKTICK = re.compile(r"`([^`]+)`")
IMAGE_LINE = re.compile(r"(?m)^!\[[^\]]*\]\((?P<url>https://[^)]+)\)\s*$")
@dataclass(frozen=True)
class FileRewrite:
path: Path
before: str
after: str
@dataclass(frozen=True)
class RewritePlan:
files: tuple[FileRewrite, ...]
issues: tuple[str, ...]
def remote_markup(url: str) -> str:
path = url.split("?", 1)[0].lower()
if path.endswith(".mp4") or "video.twimg.com" in url:
return f'<video controls src="{url}"></video>'
return f"![]({url})"
def _fallback_urls(text: str) -> list[str]:
return [
match.group("url")
for match in IMAGE_LINE.finditer(text)
if "files.catbox.moe" in match.group("url")
]
def plan_thread_rewrites(
note_dir: Path,
*,
filename_origins: dict[str, str],
fallback_origins: dict[str, list[str]],
) -> RewritePlan:
paths = sorted(note_dir.glob("*.md"), key=lambda path: (path.name != "index.md", path.name))
texts = {path: path.read_text(encoding="utf-8") for path in paths}
fallback_counts = Counter(
url for text in texts.values() for url in _fallback_urls(text)
)
fallback_files: dict[str, set[Path]] = {}
for path, text in texts.items():
for url in _fallback_urls(text):
fallback_files.setdefault(url, set()).add(path)
issues: list[str] = []
for url, count in sorted(fallback_counts.items()):
candidates = fallback_origins.get(url)
if not candidates:
issues.append(f"untracked fallback reference: {url}")
elif len(candidates) != count:
issues.append(f"fallback occurrence mismatch: {url} expected={len(candidates)} actual={count}")
elif len(candidates) > 1 and len(fallback_files[url]) > 1:
issues.append(f"fallback spans multiple notes: {url}")
if issues:
return RewritePlan((), tuple(issues))
fallback_queues = {
url: deque(origins) for url, origins in fallback_origins.items()
}
rewrites: list[FileRewrite] = []
for path in paths:
before = texts[path]
def replace_media_line(match: re.Match[str], _path=path) -> str:
names = BACKTICK.findall(match.group("body"))
if not names:
return match.group(0)
urls: list[str] = []
for name in names:
origin = filename_origins.get(name)
if origin is None:
issues.append(f"untracked local reference: {name}")
return match.group(0)
urls.append(origin)
return "\n\n".join(remote_markup(url) for url in urls)
after = MEDIA_LINE.sub(replace_media_line, before)
def replace_image_line(match: re.Match[str], _path=path) -> str:
url = match.group("url")
queue = fallback_queues.get(url)
if not queue:
return match.group(0)
return remote_markup(queue.popleft())
after = IMAGE_LINE.sub(replace_image_line, after)
if after != before:
rewrites.append(FileRewrite(path, before, after))
if issues:
return RewritePlan((), tuple(sorted(set(issues))))
return RewritePlan(tuple(rewrites), ())
def atomic_write_text(path: Path, text: str) -> None:
temp = path.with_name(f".{path.name}.{os.getpid()}.tmp")
try:
with temp.open("w", encoding="utf-8", newline="\n") as handle:
handle.write(text)
handle.flush()
os.fsync(handle.fileno())
os.replace(temp, path)
finally:
if temp.exists():
temp.unlink()
def apply_rewrite_plan(plan: RewritePlan) -> None:
if plan.issues:
raise ValueError("cannot apply a rewrite plan with issues")
for rewrite in plan.files:
atomic_write_text(rewrite.path, rewrite.after)
+23 -19
View File
@@ -18,6 +18,7 @@ import subprocess
import sys
import urllib.error
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
UMI_URL = "http://127.0.0.1:1224/api/ocr"
@@ -134,14 +135,20 @@ $result = Await ($engine.RecognizeAsync($bitmap))
def append_media_row(media_json: Path, src: Path, dest: Path) -> None:
try:
from media_manifest import atomic_write_json, upsert_derived_item
except ImportError: # pragma: no cover - script-mode import
from .media_manifest import atomic_write_json, upsert_derived_item
data = json.loads(media_json.read_text(encoding="utf-8"))
items = data.setdefault("items", [])
stem = src.name
if data.get("schema_version") != 2:
raise SystemExit(
"legacy media.json requires: tw.py migrate-media --id <id> --apply"
)
post_id = ""
media_id = ""
handle = ""
for item in items:
if item.get("filename") == src.name or item.get("filename") == dest.name:
for item in data.get("items") or []:
if str(item.get("filename") or "") in {src.name, dest.name}:
post_id = str(item.get("post_id") or "")
media_id = str(item.get("media_id") or "")
handle = str(item.get("handle") or "")
@@ -150,21 +157,18 @@ def append_media_row(media_json: Path, src: Path, dest: Path) -> None:
parts = src.stem.split("_")
post_id = parts[0]
media_id = parts[1] if len(parts) > 1 else ""
row = {
"post_id": post_id,
"media_id": media_id,
"handle": handle,
"role": "ocr",
"filename": dest.name,
"publish": False,
"url": None,
"embed": False,
"source_filename": src.name,
}
items = [i for i in items if not (i.get("role") == "ocr" and i.get("source_filename") == src.name)]
items.append(row)
data["items"] = items
media_json.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
asset_dir = media_json.parent
upsert_derived_item(
data,
post_id=post_id,
media_id=media_id,
handle=handle,
role="ocr",
filename=dest.name,
asset_dir=asset_dir,
now=datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
)
atomic_write_json(media_json, data)
print(f"updated {media_json}")
+15 -1
View File
@@ -2,11 +2,25 @@
from __future__ import annotations
import sys
from pathlib import Path
try:
from .media_refs import remote_markup
except ImportError: # pragma: no cover - script-mode import
if str(Path(__file__).resolve().parent) not in sys.path:
sys.path.insert(0, str(Path(__file__).resolve().parent))
from media_refs import remote_markup
from .models import PostData, ThreadData
from .slug import date_prefix
from .tree import by_id
def render_media(url: str) -> str:
return remote_markup(url)
def _yaml_quote(value: str) -> str:
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
@@ -91,7 +105,7 @@ def _post_block(
media = (media_by_post or {}).get(post.post_id) or ()
if media:
parts.append("")
parts.append("Media (not lifted): " + " ".join(f"`{fn}`" for fn in media))
parts.extend(render_media(url) for url in media)
if branch_links:
parts.append("")
parts.append("Branches: " + ", ".join(f"[[{name}]]" for name in branch_links))
+197 -10
View File
@@ -20,23 +20,45 @@ import subprocess
import sys
from pathlib import Path
import sys
from pathlib import Path
_HERE = Path(__file__).resolve().parent
if str(_HERE) not in sys.path:
sys.path.insert(0, str(_HERE))
from paths import COOKIES, DUMPS, FROZEN, HERE, SCRATCH, VAULT
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 fallback_media import activate_fallback, find_existing_fallback, restore_origin
from media_audit import audit_thread
from media_migrate import migrate_legacy_thread
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 fallback_media import activate_fallback, find_existing_fallback, restore_origin
from media_audit import audit_thread
from media_migrate import migrate_legacy_thread
def frozen_ids() -> set[str]:
out: set[str] = set()
if not FROZEN.is_file():
return out
for line in FROZEN.read_text(encoding="utf-8").splitlines():
s = line.strip()
if s and not s.startswith("#"):
out.add(s)
return out
return load_frozen_ids(FROZEN)
def check_frozen(post_id: str) -> None:
direct = dump_dir(post_id)
assets, _notes = locate(post_id)
candidate = assets if assets is not None else direct
if candidate.is_dir():
match = frozen_match(candidate, frozen_ids())
if match is not None:
raise SystemExit(f"frozen: skipped ({match})")
if post_id in frozen_ids():
raise SystemExit(f"frozen Onat dump {post_id} — will not refetch/overwrite")
raise SystemExit(f"frozen: skipped ({post_id})")
def dump_dir(post_id: str) -> Path:
@@ -166,6 +188,7 @@ def cmd_refetch(post_id: str) -> int:
def cmd_emit(post_id: str, tip: bool, slug: str | None) -> int:
check_frozen(post_id)
src = scratch_dir(post_id)
if not (src / "thread_data.json").is_file():
src = dump_dir(post_id)
@@ -237,6 +260,7 @@ def cmd_ocr(post_id: str, engine: str) -> int:
def cmd_merge(post_id: str) -> int:
check_frozen(post_id)
assets, _notes = locate(post_id)
if assets is None:
raise SystemExit(f"no assets thread for {post_id}")
@@ -245,6 +269,7 @@ def cmd_merge(post_id: str) -> int:
def cmd_refresh(post_id: str, tip: bool, slug: str | None) -> int:
check_frozen(post_id)
cmd_refetch(post_id)
cmd_graph(post_id)
cmd_emit(post_id, tip=tip, slug=slug)
@@ -301,6 +326,7 @@ def cmd_sync(handle: str | None) -> int:
def cmd_publish(post_id: str) -> int:
check_frozen(post_id)
_assets, notes = locate(post_id)
if notes is None:
raise SystemExit(f"no vault thread for {post_id}")
@@ -321,7 +347,113 @@ def cmd_publish(post_id: str) -> int:
return 0
def main(argv: list[str] | None = None) -> int:
def _now_iso() -> str:
from datetime import datetime, timezone
return (
datetime.now(timezone.utc)
.replace(microsecond=0)
.isoformat()
.replace("+00:00", "Z")
)
def cmd_migrate_media(args) -> int:
if bool(args.id) == bool(args.all_root):
raise SystemExit("migrate-media requires exactly one of --id or --all")
if args.id:
asset = DUMPS / args.id / "thread_data.json"
if not asset.is_file():
scratch = SCRATCH / f"refetch_{args.id}"
if not (scratch / "thread_data.json").is_file():
raise SystemExit(f"no thread_data.json for {args.id}")
assets, note_dir = locate(args.id)
if assets is None or note_dir is None:
raise SystemExit(f"no vault thread for {args.id}")
check_frozen(args.id)
result = migrate_legacy_thread(assets, note_dir, now=_now_iso(), apply=args.apply)
print(result)
return 0 if result.state != "blocked" else 2
if not args.apply:
print("dry-run: explicit --apply required to write")
return 0
print("corpus migrate-media --apply not yet implemented; awaiting Task 11")
return 0
def cmd_audit_media(args) -> int:
assets, note_dir = locate(args.id)
if assets is None or note_dir is None:
raise SystemExit(f"no vault thread for {args.id}")
report = audit_thread(assets, note_dir, load_frozen_ids(FROZEN))
if report.issues:
for issue in report.issues:
print(issue)
return 1
print("audit ok")
return 0
def cmd_backup(args) -> int:
check_frozen(args.id)
assets, note_dir = locate(args.id)
if assets is None or note_dir is None:
raise SystemExit(f"no vault thread for {args.id}")
destination_root = load_destination_root(VAULT, args.destination)
result = backup_thread(
assets,
assets_root=VAULT / "assets" / "threads",
destination_root=destination_root,
destination_id=args.destination,
now=_now_iso(),
require_destination_root=True,
)
print(result)
return 0 if result.state == "synced" else 2
def cmd_fallback(args) -> int:
if not args.confirm_origin_unavailable:
raise SystemExit("fallback requires --confirm-origin-unavailable")
check_frozen(args.id)
assets, note_dir = locate(args.id)
if assets is None or note_dir is None:
raise SystemExit(f"no vault thread for {args.id}")
userhash = load_userhash(VAULT)
assets_root = VAULT / "assets" / "threads"
def _upload(path: Path) -> str:
return upload_file(path, userhash)
def _lookup(provider: str, content_hash: str) -> str | None:
return find_existing_fallback(assets_root, provider, content_hash)
result = activate_fallback(
assets,
note_dir,
media_id=args.media_id,
role=args.role,
provider=args.provider,
confirm_origin_unavailable=True,
now=_now_iso(),
upload=_upload,
lookup=_lookup,
)
print(result)
return 0
def cmd_restore_origin(args) -> int:
check_frozen(args.id)
assets, note_dir = locate(args.id)
if assets is None or note_dir is None:
raise SystemExit(f"no vault thread for {args.id}")
restore_origin(assets, note_dir, media_id=args.media_id, now=_now_iso())
print("restored origin")
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
sub = parser.add_subparsers(dest="cmd", required=True)
for name in (
@@ -352,6 +484,51 @@ def main(argv: list[str] | None = None) -> int:
"sync", help="rebuild handle-index wikilinks from actual folder listing"
)
sync.add_argument("--handle", default=None, help="single handle, else all")
migrate = sub.add_parser(
"migrate-media", help="convert legacy media.json to canonical locations"
)
migrate.add_argument("--id", default=None)
migrate.add_argument("--all", dest="all_root", action="store_true")
migrate.add_argument("--apply", action="store_true")
audit = sub.add_parser(
"audit-media", help="local integrity and reference check"
)
audit.add_argument("--id", required=True)
audit.add_argument("--check-origin", action="store_true")
audit.add_argument("--record-checks", action="store_true")
backup_p = sub.add_parser(
"backup", help="sparse, hash-verified copy of one thread asset dir"
)
backup_p.add_argument("--id", required=True)
backup_p.add_argument("--destination", default="cozy")
fallback = sub.add_parser(
"fallback", help="upload one confirmed fallback host selection"
)
fallback.add_argument("--id", required=True)
fallback.add_argument("--media-id", required=True)
fallback.add_argument("--role", default="orig")
fallback.add_argument(
"--provider", default="catbox", choices=("catbox",)
)
fallback.add_argument(
"--confirm-origin-unavailable", action="store_true"
)
restore = sub.add_parser(
"restore-origin", help="select the immutable origin again"
)
restore.add_argument("--id", required=True)
restore.add_argument("--media-id", required=True)
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
post_id = getattr(args, "id", None)
if args.cmd == "locate":
@@ -374,6 +551,16 @@ def main(argv: list[str] | None = None) -> int:
return cmd_publish(post_id)
if args.cmd == "sync":
return cmd_sync(handle=args.handle)
if args.cmd == "migrate-media":
return cmd_migrate_media(args)
if args.cmd == "audit-media":
return cmd_audit_media(args)
if args.cmd == "backup":
return cmd_backup(args)
if args.cmd == "fallback":
return cmd_fallback(args)
if args.cmd == "restore-origin":
return cmd_restore_origin(args)
raise SystemExit(f"unknown {args.cmd}")