fix: link file cards to the entry that reads them

A directory entry was joined to the collection by its own name, so the
11,976 files under alephone's Plugins/ and the cdogs, thextech and
easyrpg trees named no emulator on their card. FileConsumers now expands
the directory and records which entry reads each file. The emulator links
on a file card land on that entry, and a directory card names its copies
and the folder that holds them.
This commit is contained in:
Abdessamad Derraz committed 2026-10-21 05:12:33 +02:00
1 parent 90e54dcb43
commit 0145e6bfc8
3 files changed
+158 -5

No files matched your search

+44 -4
View File
@@ -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:
+54 -1
View File
@@ -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.
+60
View File
@@ -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()