From 0d586ef6bf7bb902ead14e5f7cb7e3b1875cb37c Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Mon, 19 Oct 2026 04:40:21 +0200 Subject: [PATCH] feat: give every file an address by content --- docs_assets/site.js | 30 ++++++++++++++ schemas/site-api-envelope.schema.json | 3 +- scripts/generate_site.py | 57 ++++++++++++++++++++++++++- tests/test_site_exports.py | 27 +++++++++++++ 4 files changed, 115 insertions(+), 2 deletions(-) diff --git a/docs_assets/site.js b/docs_assets/site.js index f57c9151..7dba3f05 100644 --- a/docs_assets/site.js +++ b/docs_assets/site.js @@ -95,9 +95,39 @@ }); }; + // files/?sha1= is a file's content address: the card it names may + // sit on another manufacturer's page today than when the link was cited. + const resolveFileAddress = () => { + const status = document.getElementById("rb-file-lookup-status"); + if (!status) return; + const sha1 = (new URLSearchParams(window.location.search).get("sha1") || "") + .trim().toLowerCase(); + if (!sha1) return; + if (!/^[0-9a-f]{40}$/.test(sha1)) { + status.textContent = "A SHA1 is 40 hexadecimal characters."; + return; + } + status.textContent = "Looking it up..."; + const index = new URL("../api/v1/file-locations.json", window.location.href); + fetch(index) + .then((response) => response.json()) + .then((document_) => { + const item = (document_.items || []).find((entry) => entry.sha1 === sha1); + if (item) { + window.location.replace(new URL("../" + item.url, window.location.href)); + } else { + status.textContent = "The collection holds no file with this SHA1."; + } + }) + .catch(() => { + status.textContent = "The file index could not be read."; + }); + }; + const enhance = () => { enhanceTables(); nameMaterialWidgets(); + resolveFileAddress(); }; if (window.document$?.subscribe) { diff --git a/schemas/site-api-envelope.schema.json b/schemas/site-api-envelope.schema.json index 3bd965e9..1633cbea 100644 --- a/schemas/site-api-envelope.schema.json +++ b/schemas/site-api-envelope.schema.json @@ -14,7 +14,8 @@ "catalog", "platforms", "emulators", - "verification-and-coverage-gaps" + "verification-and-coverage-gaps", + "file-locations" ] }, "count": { "type": "integer", "minimum": 0 }, diff --git a/scripts/generate_site.py b/scripts/generate_site.py index 6da4ed8f..f3662613 100644 --- a/scripts/generate_site.py +++ b/scripts/generate_site.py @@ -930,6 +930,7 @@ def generate_data_exports( "platforms.json": ("platforms", platform_items), "emulators.json": ("emulators", emulator_items), "gaps.json": ("verification-and-coverage-gaps", gap_rows), + "file-locations.json": ("file-locations", file_location_items(db)), } for filename, (kind, items) in envelopes.items(): document = { @@ -1034,6 +1035,7 @@ def generate_data_exports( (api / "platforms.json", "Platform contracts", "application/json", "schemas/site-api-envelope.schema.json"), (api / "emulators.json", "Emulator profiles", "application/json", "schemas/site-api-envelope.schema.json"), (api / "gaps.json", "Verification and coverage gaps", "application/json", "schemas/site-api-envelope.schema.json"), + (api / "file-locations.json", "Where each file's card is, by SHA1", "application/json", "schemas/site-api-envelope.schema.json"), (api / "stats.json", "Project statistics", "application/json", "schemas/stats.schema.json"), (downloads / "files.csv", "File hashes CSV", "text/csv", None), (downloads / "retrobios.dat", "Collection DAT (Logiqx XML, for clrmamepro and RomVault)", @@ -1936,6 +1938,51 @@ def generate_platform_page( # System pages +def generate_files_page() -> str: + """The content address of every held file: files/?sha1=. + + site.js reads api/v1/file-locations.json and goes to the card; without + a sha1, or with one the collection does not hold, the page says so. + """ + return "\n".join([ + f"# Find a file by its SHA1 - {SITE_NAME}", + "", + "A file's card lives on its manufacturer's page, and a file refiled " + "between manufacturers moves with it. `files/?sha1=` is the " + "address that does not move: it opens the card wherever it is now. " + "Each card's **permalink** is that address.", + "", + '
', + ' ', + ' ', + '', + "
", + "", + '

', + "", + "The index behind it is [`api/v1/file-locations.json`](../api/v1/file-locations.json).", + "", + ]) + + +def file_location_items(db: dict) -> list[dict]: + """Each held file's card, keyed by its sha1. + + A card lives on its manufacturer's page, and a file refiled between + manufacturers (mt32_control.rom, Commodore to Roland) moved its link + with it. files/?sha1= reads this list and goes to the card wherever it + is now: the address cited depends on the content only. + """ + items = [] + for sha1, entry in sorted(db.get("files", {}).items()): + parts = entry.get("path", "").split("/") + if len(parts) < 3 or parts[0] != "bios": + continue + slug = parts[1].lower().replace(" ", "-") + items.append({"sha1": sha1, "url": f"systems/{slug}/#sha1-{sha1[:12]}"}) + return items + + def _group_by_manufacturer(db: dict) -> dict[str, dict[str, list]]: """Group files by manufacturer -> console -> files.""" manufacturers: dict[str, dict[str, list]] = {} @@ -2268,7 +2315,13 @@ def _system_file_card(f: dict, consumers: FileConsumers) -> list[str]: anchor = f"sha1-{sha1_full[:12]}" if sha1_full != "unknown" else "" id_attr = f' id="{anchor}"' if anchor else "" lines = [f'
', ""] - permalink = f' #' if anchor else "" + # The anchor is this page's; the second link is the file's own address, + # resolved by content wherever its card moves. + permalink = ( + f' #' + f' permalink' + ) if anchor else "" lines.append(f'**`{name}`** {size}{permalink}') lines.append("") if f.get("path"): @@ -4481,6 +4534,8 @@ def _generate(args: argparse.Namespace) -> None: write_if_changed( str(docs / "systems" / "index.md"), generate_systems_index(manufacturers) ) + (docs / "files").mkdir(parents=True, exist_ok=True) + write_if_changed(str(docs / "files" / "index.md"), generate_files_page()) for mfr, consoles in manufacturers.items(): slug = mfr.lower().replace(" ", "-") page = generate_system_page(mfr, consoles, consumers) diff --git a/tests/test_site_exports.py b/tests/test_site_exports.py index cec4d00c..412b6145 100644 --- a/tests/test_site_exports.py +++ b/tests/test_site_exports.py @@ -651,5 +651,32 @@ class APerCoreDocumentValidates(unittest.TestCase): jsonschema.validate(document["items"][0]["profile"], schema) +class AFileHasAnAddressByContent(unittest.TestCase): + """mt32_control.rom moved from the Commodore page to the Roland page and + every link to its card broke: the anchor was content-based, the page + was not.""" + + def test_index_page_and_resolver(self): + import generate_site # noqa: PLC0415 + + db = {"files": { + "ab" * 20: {"path": "bios/Roland/mt32-roms/mt32_control.rom"}, + "cd" * 20: {"path": "data/elsewhere.bin"}, + }} + self.assertEqual( + generate_site.file_location_items(db), + [{"sha1": "ab" * 20, "url": "systems/roland/#sha1-" + "ab" * 6}], + ) + self.assertIn('id="rb-file-lookup-status"', generate_site.generate_files_page()) + script = (Path(__file__).resolve().parents[1] / "docs_assets" / "site.js").read_text() + self.assertIn("file-locations.json", script) + from cross_reference import FileConsumers # noqa: PLC0415 + + card = "\n".join(generate_site._system_file_card( + {"name": "x.rom", "sha1": "ab" * 20, "size": 1}, FileConsumers({"files": {}}), + )) + self.assertIn("../../files/?sha1=" + "ab" * 20, card) + + if __name__ == "__main__": unittest.main()