diff --git a/schemas/site-api-envelope.schema.json b/schemas/site-api-envelope.schema.json index da48f568..5372333e 100644 --- a/schemas/site-api-envelope.schema.json +++ b/schemas/site-api-envelope.schema.json @@ -407,6 +407,10 @@ }, "owner": { "type": "string" + }, + "system_name": { + "type": "string", + "description": "The name the site gives the system the record belongs to (Sony PlayStation): the console a collection file is filed under, the system a platform lists it in, the system of an emulator's entry. Absent when none is known." } } } diff --git a/scripts/generate_site.py b/scripts/generate_site.py index 4319a7f8..079387a7 100644 --- a/scripts/generate_site.py +++ b/scripts/generate_site.py @@ -1792,7 +1792,8 @@ def generate_data_page(exports: list[dict]) -> str: "is the first two characters of the hash in lowercase, holds every SHA1, " "MD5, CRC32 and SHA256 starting with them. Each record names its `layer` " "(the collection, a data directory, a platform's list or an emulator " - "profile), the file `name` and the `url` of the page that shows it. " + "profile), the file `name`, the `url` of the page that shows it and, " + "when one is known, the `system_name` the site gives its system. " "[Find a file](files/index.md) reads it.", "", "Two kinds of `url`:", @@ -3651,6 +3652,30 @@ def file_location_items( LOOKUP_PREFIX = 2 # hex characters naming a lookup shard: 256 files +def _lookup_system_name(sys_id: str) -> str | None: + """The system's name as every page prints it, None for an id the site + cannot name. The Find a file page reads it, and never builds one.""" + try: + return system_name(sys_id) + except KeyError: + return None + + +def _console_system_names(profiles: dict, coverages: dict) -> dict[tuple[str, str], str]: + """(manufacturer slug, console folder) -> the name of the system whose + own section that folder is: a collection file is named after the console + it is filed under. A folder no id owns, or that two names own, is left out. + """ + from siterender import _own_section, system_ids + + found: dict[tuple[str, str], set[str]] = {} + for sys_id in system_ids(profiles, [c["config"] for c in coverages.values()]): + own = _own_section(sys_id) + if own is not None: + found.setdefault((_slug(own[0]), own[1]), set()).add(system_name(sys_id)) + return {key: next(iter(names)) for key, names in found.items() if len(names) == 1} + + def file_lookup_records( db: dict, coverages: dict, @@ -3666,6 +3691,7 @@ def file_lookup_records( it made every visitor download: the lookup reads one shard instead. """ files = db.get("files", {}) + named = _console_system_names(profiles, coverages) member_names = { str(info.get("sha1", "")): member.rsplit("/", 1)[-1] for tree in (members or {}).values() for member, info in sorted(tree.items()) @@ -3680,28 +3706,37 @@ def file_lookup_records( "name": member_names.get(sha1, ""), "sha1": sha1, "url": item["url"]}) continue + parts = str(entry.get("path", "")).split("/") + held_by = named.get((_slug(parts[1]), parts[2])) if len(parts) > 3 else None for algorithm in HASH_ALGORITHMS: for value in _hash_values(entry.get(algorithm)): records.append({ "value": value, "algorithm": algorithm, "layer": "collection", "name": str(entry.get("name", "")), "sha1": sha1, "url": item["url"], + **({"system_name": held_by} if held_by else {}), }) for key, coverage in sorted(coverages.items()): for system_id, system in sorted((coverage["config"].get("systems") or {}).items()): url = _site_path(f"platforms/{key}.md#{system_id.replace(' ', '-')}") + system_label = _lookup_system_name(system_id) for entry in system.get("files") or []: for algorithm in HASH_ALGORITHMS: for value in _hash_values(entry.get(algorithm)): records.append({ "value": value, "algorithm": algorithm, "layer": "platform", "owner": key, "name": str(entry.get("name", "")), "url": url, + **({"system_name": system_label} if system_label else {}), }) for key, profile in sorted(profiles.items()): if profile.get("type") in ("alias", "test"): continue entries = profile.get("files") or [] + own_systems = [str(s) for s in profile.get("systems") or []] for entry, anchor in zip(entries, emulator_file_anchors(entries)): url = _site_path(f"emulators/{emulator_card_page(key, anchor)}.md#{anchor}") + sys_id = str(entry.get("system") or "") or ( + own_systems[0] if len(own_systems) == 1 else "") + entry_label = _lookup_system_name(sys_id) if sys_id else None members = [(str(entry.get("name", "")), entry)] + [ (str(member["name"]), member) for member in entry.get("contents") or [] if isinstance(member, dict) and member.get("name") @@ -3712,6 +3747,7 @@ def file_lookup_records( records.append({ "value": value, "algorithm": algorithm, "layer": "emulator", "owner": key, "name": name, "url": url, + **({"system_name": entry_label} if entry_label else {}), }) unique = {json.dumps(r, sort_keys=True): r for r in records} return sorted(unique.values(), key=lambda r: ( @@ -7109,7 +7145,7 @@ def generate_gap_analysis( ) req = "yes" if m["required"] else "no" algorithm, _, value = m["identity"].partition(":") - expected = f"{algorithm} {_code(value)}" if value else "-" + expected = f"{algorithm} {short_hash(value)}" if value else "-" destination = _code(m["path"]) if m["path"] != m["name"] else "-" emulators = ", ".join( f"[{_escape_tags(display)}](emulators/{key}.md)" @@ -7167,7 +7203,7 @@ def generate_gap_analysis( ]) for u in sorted(all_unsourceable, key=lambda x: x["name"]): lines.append( - f"| `{u['name']}` | {u['emulator']} | {u['reason']} " + f"| `{u['name']}` | {u['emulator']} | {_reason_cell(u['reason'])} " f"| {_source_ref_markdown(profiles[u['emu_key']], u['source_ref'])} |" ) lines.append("") diff --git a/tests/test_site_r3_reference.py b/tests/test_site_r3_reference.py index fad13ffe..d631c7ba 100644 --- a/tests/test_site_r3_reference.py +++ b/tests/test_site_r3_reference.py @@ -336,6 +336,30 @@ class LookupUrlsResolve(unittest.TestCase): shard = SITE + "api/v1/lookup/ab.json" self.assertTrue(urllib.parse.urljoin(shard, item["url"]).startswith(SITE + "systems/")) + def test_each_record_names_its_system_as_the_pages_do(self): + """Find a file opens on "a BIOS file for Sony PlayStation": the name + comes from system_name, never built in the browser.""" + from siterender import system_name # noqa: PLC0415 + + db = {"files": {"ab" * 20: {"path": "bios/Sony/PlayStation/scph5501.bin", + "name": "scph5501.bin", "md5": "490f666e1afb15b7362b406ed1cea246"}}} + coverages = {"batocera": {"platform": "Batocera", "config": {"systems": { + "sony-playstation": {"files": [{"name": "scph5501.bin", "md5": "490f666e1afb15b7362b406ed1cea246"}]}}}}} + profiles = {"duckstation": {"systems": ["sony-playstation"], "files": [ + {"name": "scph5501.bin", "md5": "490f666e1afb15b7362b406ed1cea246"}]}} + records = generate_site.file_lookup_records( + db, coverages, profiles, generate_site.file_location_items(db)) + expected = system_name("sony-playstation") + by_layer = {r["layer"]: r for r in records if r["algorithm"] == "md5"} + for layer in ("collection", "platform", "emulator"): + with self.subTest(layer=layer): + self.assertEqual(by_layer[layer]["system_name"], expected) + + def test_built_shards_carry_the_name(self): + shard = json.loads(_built(API / "lookup" / "05.json").read_text()) + record = next(r for r in shard["items"] if r["value"].startswith("0555c6fae890")) + self.assertEqual(record["system_name"], "Sony PlayStation") + def test_built_records_land_on_built_pages(self): shard = json.loads(_built(API / "lookup" / "05.json").read_text()) dist = REPO_ROOT / "website" / "dist" @@ -401,6 +425,9 @@ class TablesFitTheirColumn(unittest.TestCase): row = next(line for line in table.splitlines() if "x.bin" in line) self.assertIn(f'{"b" * 12}', row) self.assertIn('expected md5 differs', row) + source = Path(generate_site.__file__).read_text(encoding="utf-8") + self.assertIn('expected = f"{algorithm} {short_hash(value)}"', source) + self.assertIn("{_reason_cell(u['reason'])}", source) class SourceLinksGoWhereTheFileIs(unittest.TestCase):