From 5eabbb9d3dc32954346ceac46b45247a7d7059c5 Mon Sep 17 00:00:00 2001 From: nichinichisou Date: Mon, 5 Oct 2026 00:16:30 +0800 Subject: [PATCH] feat(catalogs): record the resource version of an imported catalog `catalogs import` now records a resource version without `--resource-version`: a JP catalog's source version, else the version in an international file name `catalog__.bin` (none for `main`), the name `fetch` and the cache use. The version also becomes the default label. An invalid `--resource-version`, or one that differs from a JP catalog's source version, is an error. Co-Authored-By: Claude Opus 5.5 --- docs/assets.md | 8 +++++--- src/nnnotes/cli_assets.py | 34 ++++++++++++++++++++++++++++++---- tests/test_cli_assets.py | 24 ++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 7 deletions(-) diff --git a/docs/assets.md b/docs/assets.md index 15ce34c..e71c860 100644 --- a/docs/assets.md +++ b/docs/assets.md @@ -25,7 +25,7 @@ nnnotes export -o OUT [--layout original[,cas]] [--select group:G|key:PREFIX|bun nnnotes plan [the selection and parameters of export] [-o OUT] [--json] [--since RUN] [--check] [--census] [--emit-tasks DIR] [--why TASK] nnnotes run-stage TASK.json|DIR [...] [--store DIR] [--fetch] [--force] -nnnotes catalogs list [--json] | import FILE [--label L] [--apk-catalog FILE] | fetch [--label L] +nnnotes catalogs list [--json] | import FILE [--label L] [--apk-catalog FILE] [--resource-version V] | fetch [--label L] | diff OLD NEW [--json] nnnotes store verify [--quick] ``` @@ -109,8 +109,10 @@ The catalog versions of a store: `import` a catalog file (with the APK's catalog them (labels, version ids or sha256 prefixes): keys added, removed and changed, bundles and raw files by stable name, the keys that reach a changed file, and, when a run over each version has stored its address table, the objects by stable address (an object keeps its stable address while its object id changes with its bundle's content). A version -is labelled by `--label`, else by the resource version (`fetch`, when the region has an API root), else by the -first 12 hex digits of its sha256. `export` imports the current catalog when it is not imported yet. +is labelled by `--label`, else by its resource version, else by the first 12 hex digits of its sha256. `fetch` +records the resource version it downloads (none for `main`); `import` takes `--resource-version`, else the JP +catalog's source version, else the version in a file name `catalog__.bin` as `fetch` and the +cache name them (none for `main`). `export` imports the current catalog when it is not imported yet. ### store verify diff --git a/src/nnnotes/cli_assets.py b/src/nnnotes/cli_assets.py index b027ce4..2803f09 100644 --- a/src/nnnotes/cli_assets.py +++ b/src/nnnotes/cli_assets.py @@ -8,7 +8,7 @@ nnnotes plan [the selection and parameters of export] [-o OUT] [--json] [--since RUN] [--check] [--census] [--emit-tasks DIR] [--why TASK] nnnotes run-stage TASK.json|DIR [...] [--store DIR] [--fetch] [--force] - nnnotes catalogs list [--json] | import FILE [--label L] | fetch [--label L] | diff A B [--json] + nnnotes catalogs list [--json] | import FILE [--label L] [--resource-version V] | fetch [--label L] | diff A B [--json] nnnotes store verify [--quick] The pipeline is the registry STAGE_SOURCES in order: each entry names the module and attribute of a stage (a Stage @@ -47,6 +47,7 @@ import argparse import fnmatch import json +import re import sys import threading import zipfile @@ -60,7 +61,7 @@ from .apkset import ApkSet from . import contract -from .config import ConfigError, describe as describe_setting, usable_cpus, use +from .config import ConfigError, check_catalog_version, describe as describe_setting, usable_cpus, use from .contract import Cost, IncompatibleTask, Input, Task from .stages import Env, Pending, Stage, describe, execute @@ -1335,6 +1336,28 @@ def _apk_catalog(args, cfg) -> bytes | None: return apk_catalog(apk) +CATALOG_FILE = re.compile(r"catalog_([A-Za-z0-9][A-Za-z0-9.-]*)_[A-Za-z0-9-]+\.bin") + + +def _import_resource_version(given: str | None, file: Path, source: dict | None) -> str | None: + """The resource version of an imported catalog: `given` (--resource-version), else the JP source's version, else + the version in an international file name `catalog__.bin` (none for `main`). A JP source is + authoritative: a different `given` is an error.""" + if given is not None: + try: + check_catalog_version(given) + except ValueError as e: + raise ValueError(f"--resource-version {given!r}: {e}") from None + if source is not None: + if given is not None and given != source["version"]: + raise ValueError(f"--resource-version {given} differs from the catalog's source version {source['version']}") + return source["version"] + if given is not None: + return given + m = CATALOG_FILE.fullmatch(file.name) + return m[1] if m and m[1] != "main" and ".." not in m[1] else None + + def cmd_catalogs_import(args, cfg, common): try: remote = Path(args.file).read_bytes() @@ -1345,9 +1368,10 @@ def cmd_catalogs_import(args, cfg, common): if cfg.provider() == "jp": from .jp import read_source source = read_source(args.file, remote).to_dict() + resource = _import_resource_version(args.resource_version, Path(args.file), source) v = _db(args, cfg).add(remote, _apk_catalog(args, cfg), label=args.label, region=cfg.get("catalog", "region"), language=cfg.get("catalog", "language"), - resource_version=args.resource_version, + resource_version=resource, apk_version_name=_apk_version(cfg.path("paths", "apk")), source=source) except ValueError as e: args.usage(str(e)) @@ -1525,7 +1549,9 @@ def bind(fn): m.add_argument("file", help="catalog__.bin") m.add_argument("--label", help="label of the version (default: its resource version, else the sha256 prefix)") m.add_argument("--apk-catalog", help="the APK's catalog.bin (default: read from [paths] apk)") - m.add_argument("--resource-version", help="the game's resource version of this catalog") + m.add_argument("--resource-version", + help="the game's resource version of this catalog (default: the JP source's version, else the " + "version in the file name catalog__.bin)") _store_arg(m) m.set_defaults(func=bind(cmd_catalogs_import), usage=m.error) m = csub.add_parser("fetch", help="fetch the region's current catalog and import it") diff --git a/tests/test_cli_assets.py b/tests/test_cli_assets.py index 6a5587d..c4c6943 100644 --- a/tests/test_cli_assets.py +++ b/tests/test_cli_assets.py @@ -498,6 +498,30 @@ def test_catalog_versions_import_list_and_diff(tmp_path, capsys): assert nn(capsys, "catalogs", "diff", "v1", "v9", *s)[0] == 2 +def test_catalog_import_takes_the_resource_version_from_the_file_name(tmp_path, capsys): + s = ["--store", tmp_path / "s"] + named = tmp_path / "catalog_1.0.0.201_en.bin" + named.write_bytes(catalog_bytes()) + main = tmp_path / "catalog_main_en.bin" + main.write_bytes(synth.CatalogWriter().build([(NAMES["shared"], synth.remote(NAMES["shared"]), [])])) + assert nn(capsys, "catalogs", "import", named, *s)[0] == 0 + assert nn(capsys, "catalogs", "import", main, *s)[0] == 0 + vs = json.loads(nn(capsys, "catalogs", "list", "--json", *s)[1])["versions"] + assert [(v["labels"], v["resourceVersion"]) for v in vs] == [ + (["1.0.0.201"], "1.0.0.201"), ([contract.sha256(main.read_bytes())[:12]], None)] + + +def test_catalog_import_resource_version_option(): + file = Path("catalog_1.0.0.201_en.bin") + assert cli_assets._import_resource_version("1.0.0.300", file, None) == "1.0.0.300" + assert cli_assets._import_resource_version(None, Path("renamed.bin"), None) is None + assert cli_assets._import_resource_version(None, file, {"version": "1.0.0.300"}) == "1.0.0.300" + with pytest.raises(ValueError, match="differs from the catalog's source version"): + cli_assets._import_resource_version("1.0.0.201", file, {"version": "1.0.0.300"}) + with pytest.raises(ValueError, match="--resource-version"): + cli_assets._import_resource_version("../x", file, None) + + def test_store_verify(tmp_path, capsys, fake): d = setup_data(tmp_path) export(capsys, tmp_path, d, "s", "o")