diff --git a/scripts/cross_reference.py b/scripts/cross_reference.py index 5ba4cc83..fb6bb1b3 100644 --- a/scripts/cross_reference.py +++ b/scripts/cross_reference.py @@ -27,6 +27,7 @@ from common import ( ProfileSelectionError, _norm_system_id, directory_members, + expand_directory_entries, get_mame_clone_map, list_emulator_profiles, list_registered_platforms, @@ -341,6 +342,10 @@ class FileConsumers: # Where each platform puts the file in its BIOS directory. self.platform_destinations: dict[str, dict[str, set[str]]] = {} self.emulators: dict[str, set[str]] = {} + # The entry that reads each file, by its position in the profile's + # files: a link lands on that entry's card, not the page top. + self.emulator_entries: dict[str, dict[str, int]] = {} + self.emulator_files: dict[str, list] = {} # Where each platform's pack installs the file, its cores' files # included: the platform's own list names a fraction of them. self.installed: dict[str, dict[str, set[str]]] = {} @@ -401,14 +406,49 @@ class FileConsumers: """The collection path the database records for a file.""" return str(self._db.get("files", {}).get(sha1, {}).get("path", "")) + def directory_members(self, entry: dict) -> dict[str, str]: + """{destination: sha1} of the files a ``type: directory`` entry reads.""" + if entry.get("type") != "directory": + return {} + return { + member["path"]: member["sha1"] + for member in expand_directory_entries([entry], self._db) + if member is not entry + } + def add_emulators(self, profiles: dict[str, dict]) -> None: + """Each file a profile entry resolves to, a directory's members included. + + A directory entry was resolved by its own name and found nothing: + 11,976 files under alephone's Plugins/, cdogs' and thextech's trees + named no emulator on their card. + """ for name, profile in profiles.items(): if profile.get("type") == "alias": continue - for entry in profile.get("files", []): - sha1 = self.sha1_of(entry, entry.get("path") or "", owner=name) - if sha1: - self.emulators.setdefault(sha1, set()).add(name) + files = profile.get("files") or [] + self.emulator_files[name] = files + # A file a profile names keeps its own entry over the directory + # that also holds it. + order = sorted( + (i for i, entry in enumerate(files) if isinstance(entry, dict)), + key=lambda i: files[i].get("type") == "directory", + ) + for index in order: + entry = files[index] + members = self.directory_members(entry) + if members: + found = [ + self.sha1_of({**entry, "name": dest, "path": dest, "sha1": sha1}, + dest, owner=name) + for dest, sha1 in members.items() + ] + else: + found = [self.sha1_of(entry, entry.get("path") or "", owner=name)] + for sha1 in found: + if sha1: + self.emulators.setdefault(sha1, set()).add(name) + self.emulator_entries.setdefault(sha1, {}).setdefault(name, index) def entry_source(f: dict, index: dict) -> str | None: diff --git a/scripts/generate_site.py b/scripts/generate_site.py index 673dc00a..897239b2 100644 --- a/scripts/generate_site.py +++ b/scripts/generate_site.py @@ -2415,6 +2415,36 @@ def _platform_links(sha1: str, consumers: FileConsumers) -> list[str]: return links +_card_anchors: dict[str, tuple[list, list[str]]] = {} + + +def _emulator_card_anchor(emulator: str, files: list, index: int) -> str: + """The id of a profile entry's card on its emulator page.""" + cached = _card_anchors.get(emulator) + if cached is None or cached[0] is not files: + cached = _card_anchors[emulator] = (files, emulator_file_anchors(files)) + return cached[1][index] if 0 <= index < len(cached[1]) else "" + + +def _emulator_links(sha1: str, consumers: FileConsumers) -> list[str]: + """Each emulator reading the file, linked to the entry that reads it. + + The platform links landed on their system section, and the emulator + links on the page top: duckstation's scph5501.bin entry, with the line + that accepts it, sat 260 lines down a page of 105 entries. + """ + entries = getattr(consumers, "emulator_entries", {}).get(sha1, {}) + files_of = getattr(consumers, "emulator_files", {}) + links = [] + for emulator in sorted(consumers.emulators.get(sha1, ())): + index = entries.get(emulator) + anchor = (_emulator_card_anchor(emulator, files_of[emulator], index) + if index is not None and emulator in files_of else "") + fragment = f"#{anchor}" if anchor else "" + links.append(f"[{emulator}](../emulators/{emulator}.md{fragment})") + return links + + def _installed_links(sha1: str, consumers: FileConsumers, declared: set[str]) -> list[str]: """Each pack installing the file that its platform's list does not name, with the place the pack gives it.""" @@ -2482,7 +2512,7 @@ def _system_file_card( # pack installs it for six cores. lines.append(f"- Also installed by: {', '.join(installed)}") if emus: - lines.append(f"- Emulators: {', '.join(_emulator_link(e, '../') for e in emus)}") + lines.append(f"- Emulators: {', '.join(_emulator_links(sha1_full, consumers))}") provenance = f.get("provenance", {}) if provenance: lines.extend(_provenance_lines(provenance, "../")) @@ -2876,6 +2906,8 @@ def _render_emulator_file( details.append( f"Source: {_source_ref_markdown(profile, source_ref)}" ) + if consumers and f.get("type") == "directory": + details.extend(_directory_copies(f, consumers)) if consumers: sha1 = consumers.sha1_of(f, f.get("path") or "", owner=owner) if sha1 and in_repo: @@ -2945,6 +2977,27 @@ def _render_emulator_file( return lines +def _directory_copies(entry: dict, consumers: FileConsumers) -> list[str]: + """What the collection holds under a directory entry, and where. + + The card said "in repo" and linked none of the files: alephone's + Plugins/ holds 393 of them, each with its own card on a system page. + """ + members = consumers.directory_members(entry) + held = sorted({consumers.path_of(sha1) for sha1 in members.values()} - {""}) + if not held: + return [] + folder = os.path.commonpath(held) if len(held) > 1 else os.path.dirname(held[0]) + parts = folder.split("/") + where = _code(folder + "/") + if len(parts) >= 3 and parts[0] == "bios": + page = urllib.parse.quote(parts[1].lower().replace(" ", "-")) + where = f"[{where}](../systems/{page}.md#{_slugify_anchor(parts[2])})" + tree = f"{REPO_URL}/tree/main/{urllib.parse.quote(folder)}" + return [f"Collection copies: {_count(len(members), 'file')} under {where} " + f"([browse]({tree}))"] + + def _render_metadata_rows(profile: dict) -> list[str]: """Scalar profile fields, one table row each. diff --git a/tests/test_site_render.py b/tests/test_site_render.py index 1228703a..d5b8661a 100644 --- a/tests/test_site_render.py +++ b/tests/test_site_render.py @@ -181,5 +181,65 @@ class StructuredBlocks(unittest.TestCase): self.assertIn("end.", page) +class FileCardsLinkTheReadingEntry(unittest.TestCase): + def test_an_emulator_link_lands_on_its_entry(self): + """The platform links landed on their section, the emulator links on + the top of a page of a hundred entries.""" + import generate_site + from cross_reference import FileConsumers + + consumers = FileConsumers({"files": {}}) + files = [{"name": "scph1001.bin"}, {"name": "scph5501.bin"}, + {"name": "tmss.md", "sha1": "d0fd0c16" + "0" * 32}, + {"name": "tmss.md", "sha1": "aa" * 20}] + consumers.emulators = {"s": {"duckstation", "other"}, "t": {"blastem"}} + consumers.emulator_entries = {"s": {"duckstation": 1}, "t": {"blastem": 2}} + consumers.emulator_files = {"duckstation": files, "blastem": files} + self.assertEqual(generate_site._emulator_links("s", consumers), [ + "[duckstation](../emulators/duckstation.md#file-scph5501-bin)", + "[other](../emulators/other.md)", + ]) + self.assertEqual(generate_site._emulator_links("t", consumers), + ["[blastem](../emulators/blastem.md#file-tmss-md-d0fd0c16)"]) + + +class DirectoryMembersNameTheirEmulator(unittest.TestCase): + """The site resolved a ``type: directory`` entry by its own name: 11,976 + files under alephone's Plugins/, cdogs' and thextech's trees had no + emulator on their card, and the directory card linked none of them.""" + + SHA1 = "c63078a6c9ccaca7339a726a5d39410983933070" + + @classmethod + def setUpClass(cls) -> None: + from common import build_zip_contents_index, load_database, load_emulator_profiles + from cross_reference import FileConsumers + + cls.db = load_database(str(REPO_ROOT / "database.json")) + if cls.SHA1 not in cls.db.get("files", {}): + raise unittest.SkipTest("the collection does not hold the alephone plugin") + cls.profile = load_emulator_profiles(str(REPO_ROOT / "emulators"))["alephone"] + cls.consumers = FileConsumers(cls.db, build_zip_contents_index(cls.db)) + cls.consumers.add_emulators({"alephone": cls.profile}) + + def test_a_member_names_the_emulator_and_its_directory_entry(self): + import generate_site + + files = self.profile["files"] + index = self.consumers.emulator_entries[self.SHA1]["alephone"] + self.assertEqual(files[index].get("type"), "directory") + self.assertEqual(generate_site._emulator_links(self.SHA1, self.consumers), + ["[alephone](../emulators/alephone.md#file-marathon-plugins)"]) + + def test_the_directory_card_points_at_its_copies(self): + import generate_site + + entry = next(f for f in self.profile["files"] + if f.get("type") == "directory" and f.get("path") == "Marathon/Plugins/") + line = generate_site._directory_copies(entry, self.consumers)[0] + self.assertRegex(line, r"^Collection copies: \d+ files under " + r"\[`bios/Bungie/Marathon/Plugins/`\]\(\.\./systems/bungie\.md#") + + if __name__ == "__main__": unittest.main()