mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-11-01 07:33:45 -06:00
chore: merge the long list tables
This commit is contained in:
commit
0e2e014a96
8 files changed
+658
-27
No files matched your search
+158
-25
@@ -83,6 +83,7 @@ from generate_readme import (
|
||||
)
|
||||
from provenance_report import build_report, distinct_missing
|
||||
import sitemarkup as markup
|
||||
import siteentries
|
||||
import sitestarlight
|
||||
from siteexports import generate_exports_page, load_export_reports
|
||||
from siteguides import (
|
||||
@@ -5103,12 +5104,14 @@ def generate_provenance_page(
|
||||
# the public for 79 of them.
|
||||
derivable = derivable or {}
|
||||
derived_entries: dict[str, dict] = {}
|
||||
for data in report.values():
|
||||
derived_by_source: dict[str, int] = {}
|
||||
for source, data in report.items():
|
||||
kept = []
|
||||
for entry in data["missing"]:
|
||||
sha1 = (entry.get("sha1") or "").lower()
|
||||
if sha1 in derivable:
|
||||
derived_entries.setdefault(sha1, entry)
|
||||
derived_by_source[source] = derived_by_source.get(source, 0) + 1
|
||||
else:
|
||||
kept.append(entry)
|
||||
data["missing"] = kept
|
||||
@@ -5136,7 +5139,19 @@ def generate_provenance_page(
|
||||
for dat in entry.get("dats") or [entry.get("dat", "")]:
|
||||
by_dat.setdefault(dat, []).append(entry)
|
||||
lines.extend([f"### {label}", ""])
|
||||
blocks: list[list[str]] = []
|
||||
if moved is not None:
|
||||
where = _list_pages(
|
||||
f"provenance-{_slugify_anchor(source)}", f"Wanted from {label}",
|
||||
f"Dumps {label} catalogues and the collection lacks. Part of "
|
||||
f"[Dump provenance](provenance.md#{_slugify_anchor(label)}).",
|
||||
[], [_wanted_list(source, label, data, by_dat, derived_by_source.get(source, 0))],
|
||||
moved, parent=("Dump provenance", "/provenance/"),
|
||||
)
|
||||
lines.extend([
|
||||
f"{_count(len(data['missing']), 'dump')} in {_count(len(by_dat), 'DAT')}: "
|
||||
f"listed on {where}.", "",
|
||||
])
|
||||
continue
|
||||
for dat in sorted(by_dat):
|
||||
entries = by_dat[dat]
|
||||
table = ["| Name | Description | SHA1 |", "|------|-------------|------|"]
|
||||
@@ -5152,20 +5167,7 @@ def generate_provenance_page(
|
||||
f"`{hit['archive']}` as `{hit['member']}`."
|
||||
)
|
||||
table.append(f"| `{name}` | {desc} | `{sha1}` |")
|
||||
blocks.append(markup.details("note", f"{dat} ({len(entries)})", table))
|
||||
if moved is None:
|
||||
lines.extend(line for block in blocks for line in block)
|
||||
continue
|
||||
where = _list_pages(
|
||||
f"provenance-{_slugify_anchor(source)}", f"Wanted from {label}",
|
||||
f"Dumps {label} catalogues and the collection lacks, by DAT. Part of "
|
||||
f"[Dump provenance](provenance.md#{_slugify_anchor(label)}).",
|
||||
[], blocks, moved, parent=("Dump provenance", "/provenance/"),
|
||||
)
|
||||
lines.extend([
|
||||
f"{_count(len(data['missing']), 'dump')} in {_count(len(by_dat), 'DAT')}: "
|
||||
f"listed on {where}.", "",
|
||||
])
|
||||
lines.extend(markup.details("note", f"{dat} ({len(entries)})", table))
|
||||
|
||||
if derived_entries:
|
||||
lines.extend(
|
||||
@@ -5191,6 +5193,69 @@ def generate_provenance_page(
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def _wanted_list(source: str, label: str, data: dict, by_dat: dict[str, list],
|
||||
derived: int) -> list[str]:
|
||||
"""A catalog's wanted page: the counts, then every missing dump in one
|
||||
table folded under a filter box.
|
||||
|
||||
The page was one closed block per DAT, 46 for TOSEC: a name was found
|
||||
by opening each in turn. The box filters the whole list by name, DAT,
|
||||
description or SHA1, and says how many rows it keeps.
|
||||
"""
|
||||
missing = data["missing"]
|
||||
held = data["matched"]
|
||||
reached = held + len(missing) + derived
|
||||
dats = _count(len(data["covered_dats"]), "DAT")
|
||||
lines = [
|
||||
f"Held: **{held:,}** of {reached:,}, in the {dats} the collection reaches. "
|
||||
f"Wanted: **{len(missing):,}**, from {_count(len(by_dat), 'DAT')}.",
|
||||
"",
|
||||
]
|
||||
if derived:
|
||||
verb = "is" if derived == 1 else "are"
|
||||
lines.extend([
|
||||
f"{derived:,} more {verb} [derivable]"
|
||||
"(provenance.md#derivable-dumps-the-collection-already-produces).", ""])
|
||||
if data.get("out_of_scope"):
|
||||
lines.extend([f"{data['out_of_scope']:,} more sit in DATs the collection does not reach.", ""])
|
||||
counts = ["| DAT | Wanted |", "|-----|--------|",
|
||||
*(f"| {dat.replace('|', '-')} | {len(entries):,} |"
|
||||
for dat, entries in sorted(by_dat.items()))]
|
||||
lines.extend(markup.details("note", f"Wanted by DAT ({len(by_dat):,})", counts))
|
||||
rows = []
|
||||
for entry in sorted(missing, key=lambda e: (e["name"], e.get("sha1") or "")):
|
||||
desc = markup.inline_html(entry.get("description") or "")
|
||||
for hit in entry.get("romsets", []):
|
||||
desc += markup.inline_html(
|
||||
f" Ships in {_ROMSET_SOURCES.get(hit['source'], hit['source'])} "
|
||||
f"`{hit['archive']}` as `{hit['member']}`.")
|
||||
in_dats = ", ".join(html.escape(d) for d in (entry.get("dats") or [entry.get("dat", "")]))
|
||||
sha1 = html.escape(entry.get("sha1") or "-")
|
||||
# The search reads the name alone. The DAT names are searched once, in
|
||||
# the count table above, and the site search holds no hashes. With the
|
||||
# descriptions indexed too, the TOSEC list matched "2" 556 times
|
||||
# (years, versions) and put the PlayStation page ahead of the
|
||||
# PlayStation 2 one for "playstation 2 bios". The name and the hash
|
||||
# sit in their cells as text: a catalog name is words, and in code
|
||||
# each word wore its own box on a narrow column.
|
||||
skip = markup.search_exclude()
|
||||
rows.append("<tr>"
|
||||
f"<td>{html.escape(entry['name'], quote=False)}</td>"
|
||||
f"<td {skip}>{in_dats}</td><td {skip}>{desc or '-'}</td>"
|
||||
f'<td class="rb-hash" {skip}>{sha1}</td></tr>')
|
||||
heads = ["Name", "DAT", "Description", "SHA1"]
|
||||
lines.extend([
|
||||
siteentries.filter_box(f"rb-wanted-filter-{_slugify_anchor(source)}",
|
||||
"dumps", heads, len(rows)),
|
||||
'<details class="rb-details rb-details-note">',
|
||||
f"<summary>All {_count(len(rows), 'wanted dump')}</summary>",
|
||||
'<table class="rb-wanted" data-rb-stack="true">',
|
||||
"<thead><tr>" + "".join(f'<th scope="col">{h}</th>' for h in heads) + "</tr></thead>",
|
||||
"<tbody>", *rows, "</tbody>", "</table>", "</details>", "",
|
||||
])
|
||||
return lines
|
||||
|
||||
|
||||
_RELEASE_ASSET_NAMES: dict[str, str] | None = None
|
||||
|
||||
|
||||
@@ -7464,12 +7529,15 @@ def _render_emulator_file(
|
||||
owner: str = "",
|
||||
alias: str = "",
|
||||
data_href: str = "#data-folder",
|
||||
badges_apart: bool = False,
|
||||
) -> list[str]:
|
||||
"""Render one row of an emulator profile's file table.
|
||||
|
||||
`anchor` is the card's id on the page, so one file can be cited by URL,
|
||||
and `alias` a second id it answers to. `data_href` links the data
|
||||
folder an app reads the entry under, from the page the card lands on.
|
||||
`badges_apart`: the title holds the name alone, for a table whose
|
||||
Status column shows the badges (siteentries.py).
|
||||
|
||||
Split out of generate_emulator_page, where it was a 228-line loop body
|
||||
carrying most of that function's branching: thirty-odd optional fields,
|
||||
@@ -7518,7 +7586,8 @@ def _render_emulator_file(
|
||||
system = system_name((profile.get("systems") or [""])[0])
|
||||
label = (f"a {system} BIOS (example: {_code(fname)})" if _named_example(str(fname))
|
||||
else f"a {system} BIOS, any file name")
|
||||
lines.append(_card_title(f"{marker}{label} {badge_str}", anchor, weight=5))
|
||||
title = f"{marker}{label}" if badges_apart else f"{marker}{label} {badge_str}"
|
||||
lines.append(_card_title(title, anchor, weight=5))
|
||||
lines.append("")
|
||||
if desc:
|
||||
lines.extend([data_root_words(_escape_tags(str(desc)), data_href), ""])
|
||||
@@ -8633,6 +8702,10 @@ def _data_tree_page(ref: str, entry: dict, pages: list[tuple[str, list[str]]],
|
||||
# anchor. A count, not a size: system cards link to these cards and are
|
||||
# written before them. At 400 a part of MAME's cards was 805 KB of HTML.
|
||||
EMULATOR_CARDS_PER_PAGE = 220
|
||||
# The most cards a part of a split page holds. Each card is a table row
|
||||
# since siteentries.py, about five elements and 60 bytes more: at 220 a
|
||||
# part, MAME's first was 612 KB and 9,777 elements.
|
||||
EMULATOR_CARDS_PER_PART = 180
|
||||
|
||||
|
||||
# The headings an emulator's cards go under, in page order: what it needs
|
||||
@@ -8667,12 +8740,59 @@ def emulator_parts(name: str, files: list) -> list[tuple[str, list[int]]]:
|
||||
if len(files) <= EMULATOR_CARDS_PER_PAGE:
|
||||
return []
|
||||
order = _display_order(files)
|
||||
count = -(-len(files) // EMULATOR_CARDS_PER_PAGE)
|
||||
count = -(-len(files) // EMULATOR_CARDS_PER_PART)
|
||||
size = -(-len(files) // count)
|
||||
return [(f"{name}-files-{index + 1}", order[start:start + size])
|
||||
for index, start in enumerate(range(0, len(files), size))]
|
||||
|
||||
|
||||
# From this many entries an unsplit emulator page lists them as one table,
|
||||
# each row opening to its card (siteentries.py), in place of a summary
|
||||
# table above the cards: GroovyMAME's 171 cards read 14,400 words open.
|
||||
# A split emulator's card pages always do.
|
||||
EMULATOR_TABLE_MIN = 150
|
||||
ENTRY_HEADS = ["File", "Contents", "Status", "Hash"]
|
||||
# The end of the badge _file_badges writes for an entry of each section.
|
||||
_SECTION_BADGE = {"required": ">required</span>", "optional": ">optional</span>",
|
||||
"with_app": ">comes with the app</span>"}
|
||||
|
||||
|
||||
def _entry_rows(files: list[dict], cards: list[list[str]], profile: dict,
|
||||
available: Callable[[dict], bool]) -> list[siteentries.Row]:
|
||||
"""Each entry as a table row: its card's title and body, the number of
|
||||
members an archive holds, its badges and its hash."""
|
||||
many = len(profile.get("systems") or []) > 1
|
||||
rows = []
|
||||
for f, card in zip(files, cards):
|
||||
title, body = siteentries.card_title_and_body(card)
|
||||
for_system = system_name(str(f["system"])) if f.get("system") and many else ""
|
||||
contents = f.get("contents") or []
|
||||
badges = _file_badges(f, available(f), for_system)
|
||||
# The first badge says whether the entry is needed: under Required,
|
||||
# Optional or Comes with the app it repeats the section heading.
|
||||
# One element and two words fewer on each of MAME's 1,563 rows.
|
||||
if badges and badges[0].endswith(_SECTION_BADGE.get(_card_section(f), "\0")):
|
||||
badges = badges[1:]
|
||||
rows.append(siteentries.Row(title, body, [
|
||||
f"{len(contents):,}" if contents else "",
|
||||
" ".join(badges),
|
||||
_hash_short(f),
|
||||
]))
|
||||
return rows
|
||||
|
||||
|
||||
def _entries_table(files: list[dict], group: list[int], rows: list[siteentries.Row]) -> list[str]:
|
||||
"""The rows of ``group``, one section's entries, as one table."""
|
||||
key = _card_section(files[group[0]])
|
||||
heading = dict(CARD_SECTIONS)[key]
|
||||
return siteentries.entry_table([rows[i] for i in group], ENTRY_HEADS,
|
||||
f"rb-entry-filter-{key.replace('_', '-')}", heading,
|
||||
# The badges hide themselves from the search
|
||||
# but for the archive's name ("in pgm.zip"),
|
||||
# how a ZIP name finds the cores reading it.
|
||||
searched=frozenset({"Status"}))
|
||||
|
||||
|
||||
def _sectioned(files: list, indexes: list[int], render: Callable[[list[int]], list[str]],
|
||||
fold: bool = False) -> list[str]:
|
||||
"""Each section's heading, then ``render`` of its entries among ``indexes``.
|
||||
@@ -9102,12 +9222,18 @@ def emulator_pages(
|
||||
aliases = emulator_file_aliases(files, anchors)
|
||||
parts = emulator_parts(name, files)
|
||||
data_href = f"{name}.md#data-folder" if parts else "#data-folder"
|
||||
table_form = markup.starlight() and (bool(parts) or len(files) >= EMULATOR_TABLE_MIN)
|
||||
cards = [
|
||||
_render_emulator_file(f, profile, consumers, files, _file_available, anchor, name,
|
||||
alias, data_href)
|
||||
alias, data_href, badges_apart=table_form)
|
||||
for f, anchor, alias in zip(files, anchors, aliases)
|
||||
]
|
||||
if not parts:
|
||||
rows = (_entry_rows(files, cards, profile, _file_available) if table_form else [])
|
||||
if not parts and table_form:
|
||||
lines.extend(held)
|
||||
lines.extend(_sectioned(files, _display_order(files),
|
||||
lambda group: _entries_table(files, group, rows)))
|
||||
elif not parts:
|
||||
order = _display_order(files)
|
||||
indexed = len(files) >= EMULATOR_SUMMARY_MIN
|
||||
if indexed:
|
||||
@@ -9129,7 +9255,8 @@ def emulator_pages(
|
||||
for number, (stem, entries) in enumerate(parts, 1):
|
||||
part_pages[stem] = _emulator_part_page(
|
||||
name, emu_name, number, parts, [cards[i] for i in entries],
|
||||
[files[i] for i in entries])
|
||||
[files[i] for i in entries],
|
||||
[rows[i] for i in entries] if table_form else None)
|
||||
|
||||
lines.extend(sitemeasurements.tested_section(name, _MEASUREMENT_ITEMS.get(name, [])))
|
||||
|
||||
@@ -9259,9 +9386,16 @@ def _part_front_matter(parent: tuple[str, str], siblings: list[tuple[str, str]],
|
||||
|
||||
def _emulator_part_page(
|
||||
name: str, emu_name: str, number: int, parts: list[tuple[str, range]],
|
||||
cards: list[list[str]], files: list[dict],
|
||||
cards: list[list[str]], files: list[dict], rows: list[siteentries.Row] | None = None,
|
||||
) -> str:
|
||||
"""One page of a split emulator's cards."""
|
||||
"""One page of a split emulator's cards: with ``rows``, one table row
|
||||
each, the card under its name."""
|
||||
if rows is not None:
|
||||
def render(group: list[int]) -> list[str]:
|
||||
return _entries_table(files, group, rows)
|
||||
else:
|
||||
def render(group: list[int]) -> list[str]:
|
||||
return [line for i in group for line in cards[i]]
|
||||
first = str(files[0].get("path") or files[0].get("name", ""))
|
||||
last = str(files[-1].get("path") or files[-1].get("name", ""))
|
||||
others = ", ".join(
|
||||
@@ -9276,8 +9410,7 @@ def _emulator_part_page(
|
||||
f"This part holds {_count(len(files), 'entry', 'entries')}, "
|
||||
f"{_code(first)} to {_code(last)}. The other parts: {others}.",
|
||||
"",
|
||||
*_sectioned(files, list(range(len(files))),
|
||||
lambda group: [line for i in group for line in cards[i]]),
|
||||
*_sectioned(files, list(range(len(files))), render),
|
||||
]
|
||||
lines.extend(_next_block([f"[{emu_name}]({name}.md), the profile and every entry"]))
|
||||
lines.extend(["", _date_line()])
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Long lists as one filterable table, each row opening to its details.
|
||||
|
||||
MAME's card pages read 13,000 to 16,000 words, one card after another,
|
||||
before a reader found the archive they came for. Here each entry is one
|
||||
row: its name, the facts that tell rows apart, and the full card in a
|
||||
closed block under the name. The table is written as HTML so the block can
|
||||
hold the card's Markdown. It carries what rehype-stack-tables.mjs gives a
|
||||
Markdown table: `data-rb-stack` (one block per row on a phone) and the
|
||||
filter box above, which site.ts wires. Without JavaScript the rows show and
|
||||
every block opens. A closed block stays in the page, so the browser's find
|
||||
and the site search still reach every name it holds.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import sitemarkup as markup
|
||||
|
||||
# rehype-stack-tables.mjs RECORD_ROWS: from this many rows a table has a
|
||||
# filter box, and its stacked labels are written by site.ts, not the build.
|
||||
RECORD_ROWS = 20
|
||||
# filterPlaceholder in rehype-stack-tables.mjs: the column names offered.
|
||||
PLACEHOLDER_COLUMNS = 4
|
||||
|
||||
|
||||
@dataclass
|
||||
class Row:
|
||||
"""One entry: ``title`` is the inline HTML of its name (a heading, so
|
||||
the site search lands on it), ``body`` the Markdown lines shown when the
|
||||
row opens, ``cells`` the HTML of the other columns."""
|
||||
|
||||
title: str
|
||||
body: list[str]
|
||||
cells: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def filter_placeholder(labels: list[str]) -> str:
|
||||
""""File, contents, status..." as rehype-stack-tables.mjs words it."""
|
||||
words = [label for label in labels if label][:PLACEHOLDER_COLUMNS]
|
||||
if not words:
|
||||
return "Type to filter..."
|
||||
lowered = [word if index == 0 or not (word[:1].isupper() and word[1:2].islower())
|
||||
else word[0].lower() + word[1:] for index, word in enumerate(words)]
|
||||
return f"{', '.join(lowered)}..."
|
||||
|
||||
|
||||
def filter_box(ident: str, heading: str, labels: list[str], rows: int, noun: str = "rows") -> str:
|
||||
"""The filter box rehype-stack-tables.mjs writes above a long table."""
|
||||
return (
|
||||
f'<div class="rb-table-filter"><label for="{ident}">Filter {html.escape(heading)}</label>'
|
||||
f'<input id="{ident}" type="search" autocomplete="off" '
|
||||
f'placeholder="{html.escape(filter_placeholder(labels))}">'
|
||||
f'<span class="rb-table-filter-count" aria-live="polite">{rows:,} {noun}</span></div>'
|
||||
)
|
||||
|
||||
|
||||
def card_title_and_body(card: list[str]) -> tuple[str, list[str]]:
|
||||
"""A card's heading line and the lines under it, its wrapping block
|
||||
(the first line and the last closing tag) taken off."""
|
||||
title_at = next(index for index, line in enumerate(card) if line.startswith("<h3"))
|
||||
closing = max(index for index, line in enumerate(card) if line.strip() == "</div>")
|
||||
body = card[title_at + 1:closing]
|
||||
while body and not body[0].strip():
|
||||
body.pop(0)
|
||||
while body and not body[-1].strip():
|
||||
body.pop()
|
||||
return card[title_at], body
|
||||
|
||||
|
||||
def entry_table(rows: list[Row], heads: list[str], ident: str, heading: str,
|
||||
stacked: list[str] | None = None,
|
||||
searched: frozenset[str] = frozenset()) -> list[str]:
|
||||
"""``rows`` under ``heads`` (the first names the column of titles).
|
||||
|
||||
``stacked`` gives a column's short label on a phone, as a header's
|
||||
`title` does in a Markdown table. A column every row leaves empty
|
||||
(``""``) is not written. The site search skips every column but the
|
||||
titles and those ``searched`` names: a count or a short hash is no word
|
||||
anyone types.
|
||||
"""
|
||||
keep = [0] + [column for column in range(1, len(heads))
|
||||
if any(row.cells[column - 1] for row in rows)]
|
||||
labels = [heads[c] for c in keep]
|
||||
short = [(stacked or heads)[c] for c in keep]
|
||||
skip = markup.search_exclude()
|
||||
# The block holds the box and the table: an open card spans its width.
|
||||
lines = ["", '<div class="rb-entries-wrap">']
|
||||
if len(rows) >= RECORD_ROWS:
|
||||
lines.append(filter_box(ident, heading, labels, len(rows)))
|
||||
lines.append('<table class="rb-entries" data-rb-stack="true">')
|
||||
head_cells = ""
|
||||
for label, name in zip(labels, short):
|
||||
title = f' title="{html.escape(name)}"' if name != label else ""
|
||||
head_cells += f'<th scope="col"{title}>{html.escape(label)}</th>'
|
||||
lines.extend(["<thead>", f"<tr>{head_cells}</tr>", "</thead>", "<tbody>"])
|
||||
for row in rows:
|
||||
lines.append("<tr>")
|
||||
if row.body:
|
||||
lines.extend(["<td>", '<details class="rb-entry">', f"<summary>{row.title}</summary>",
|
||||
"", *row.body, "", "</details>", "</td>"])
|
||||
else:
|
||||
lines.append(f"<td>{row.title}</td>")
|
||||
for column in keep[1:]:
|
||||
label = (f' data-label="{html.escape(short[keep.index(column)])}"'
|
||||
if len(rows) < RECORD_ROWS else "")
|
||||
ignored = "" if heads[column] in searched else f" {skip}"
|
||||
lines.append(f"<td{label}{ignored}>{row.cells[column - 1] or '-'}</td>")
|
||||
lines.append("</tr>")
|
||||
lines.extend(["</tbody>", "</table>", "</div>", ""])
|
||||
return lines
|
||||
Reference in new issue
Block a user