Files
matrix-screen-controller/核桃派软件源代码/scripts/migrate_ui_copy_draft.py
T

158 lines
5.5 KiB
Python

from __future__ import annotations
import argparse
from datetime import datetime, timezone
import hashlib
import json
from pathlib import Path
import sys
from typing import Any
SOURCE_ROOT = Path(__file__).resolve().parents[1]
if str(SOURCE_ROOT) not in sys.path:
sys.path.insert(0, str(SOURCE_ROOT))
from app.persistence import atomic_write_many_bytes
def _read_json(path: Path) -> tuple[dict[str, Any], bytes]:
content = path.read_bytes()
value = json.loads(content.decode("utf-8"))
if not isinstance(value, dict):
raise ValueError(f"{path} must contain a JSON object")
return value, content
def _json_bytes(value: dict[str, Any]) -> bytes:
return (json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + "\n").encode("utf-8")
def _now() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
def build_migration(
draft: dict[str, Any],
old_catalog: dict[str, Any],
old_catalog_bytes: bytes,
new_catalog: dict[str, Any],
new_catalog_bytes: bytes,
*,
excluded_copy_ids: set[str],
) -> tuple[dict[str, Any], dict[str, Any]]:
if old_catalog.get("schema_version") != 1:
raise ValueError("old catalog must use schema v1")
if new_catalog.get("schema_version") != 2:
raise ValueError("new catalog must use schema v2")
if draft.get("schema_version") != 1:
raise ValueError("draft must use schema v1")
old_digest = hashlib.sha256(old_catalog_bytes).hexdigest()
if draft.get("base_catalog_digest") != old_digest:
raise ValueError("draft does not match the supplied old catalog")
overrides = draft.get("overrides")
insertions = draft.get("insertions")
if not isinstance(overrides, dict) or not isinstance(insertions, list):
raise ValueError("draft collections are invalid")
new_items = new_catalog.get("items")
new_anchors = new_catalog.get("anchors")
if not isinstance(new_items, dict) or not isinstance(new_anchors, dict):
raise ValueError("new catalog collections are invalid")
retained_overrides: dict[str, Any] = {}
removed_overrides: list[dict[str, Any]] = []
for copy_id, override in overrides.items():
if copy_id in excluded_copy_ids:
removed_overrides.append({"copy_id": copy_id, "reason": "explicitly_excluded", "override": override})
elif copy_id not in new_items:
removed_overrides.append({"copy_id": copy_id, "reason": "not_registered_in_v2", "override": override})
else:
retained_overrides[copy_id] = override
retained_insertions: list[dict[str, Any]] = []
removed_insertions: list[dict[str, Any]] = []
for insertion in insertions:
anchor = new_anchors.get(insertion.get("anchor_id")) if isinstance(insertion, dict) else None
if not isinstance(anchor, dict) or anchor.get("scope") != insertion.get("workspace_id"):
removed_insertions.append({"reason": "anchor_not_registered_in_v2", "insertion": insertion})
else:
retained_insertions.append(insertion)
migrated_at = _now()
new_digest = hashlib.sha256(new_catalog_bytes).hexdigest()
migrated = {
"schema_version": 1,
"base_catalog_digest": new_digest,
"revision": int(draft.get("revision", -1)) + 1,
"updated_at": migrated_at,
"overrides": retained_overrides,
"insertions": retained_insertions,
}
report = {
"schema_version": 1,
"migrated_at": migrated_at,
"from_catalog_digest": old_digest,
"to_catalog_digest": new_digest,
"from_revision": draft.get("revision"),
"to_revision": migrated["revision"],
"retained_override_ids": sorted(retained_overrides),
"removed_overrides": removed_overrides,
"retained_insertion_ids": [item.get("id") for item in retained_insertions],
"removed_insertions": removed_insertions,
}
return migrated, report
def migrate_files(
draft_path: Path,
old_catalog_path: Path,
new_catalog_path: Path,
report_path: Path,
*,
excluded_copy_ids: set[str],
) -> tuple[dict[str, Any], dict[str, Any]]:
draft, _draft_bytes = _read_json(draft_path)
old_catalog, old_bytes = _read_json(old_catalog_path)
new_catalog, new_bytes = _read_json(new_catalog_path)
migrated, report = build_migration(
draft,
old_catalog,
old_bytes,
new_catalog,
new_bytes,
excluded_copy_ids=excluded_copy_ids,
)
atomic_write_many_bytes(
(
(draft_path, _json_bytes(migrated)),
(report_path, _json_bytes(report)),
)
)
return migrated, report
def main() -> None:
parser = argparse.ArgumentParser(description="Migrate a UI copy draft from catalog v1 to catalog v2")
parser.add_argument("--draft", type=Path, required=True)
parser.add_argument("--old-catalog", type=Path, required=True)
parser.add_argument("--new-catalog", type=Path, required=True)
parser.add_argument("--report", type=Path, required=True)
parser.add_argument("--exclude-copy-id", action="append", default=[])
args = parser.parse_args()
migrated, report = migrate_files(
args.draft,
args.old_catalog,
args.new_catalog,
args.report,
excluded_copy_ids=set(args.exclude_copy_id),
)
print(json.dumps({
"revision": migrated["revision"],
"retained_overrides": len(report["retained_override_ids"]),
"removed_overrides": len(report["removed_overrides"]),
}, ensure_ascii=False))
if __name__ == "__main__":
main()