diff --git a/docs/new-features.md b/docs/new-features.md index 70d15d2a..ea28584b 100644 --- a/docs/new-features.md +++ b/docs/new-features.md @@ -461,9 +461,12 @@ one; paste an index URL or its `owner/repo` and it is remembered in A feed author can publish per-mod release stats by adding three optional fields to an entry -- `downloads` (total across every release), and `first_release` / `last_release` (ISO days) -- which the listing shows in -the same gold line the MODS tab uses. The fields are additive: feeds that -carry them stay readable by every build that predates them, and feeds that -do not render exactly as before. +the same gold line the MODS tab uses. When a feed does not carry them, +the row fetches the mod's own GitHub releases instead -- the same cached +`ModUpdate` fetch the MODS tab uses, one entry per frame -- so the stats +appear for any mod with a `github` field regardless of feed maintenance. +The fields are additive: feeds that carry them stay readable by every +build that predates them, and feeds that do not render exactly as before. ## Soft reset (all versions) diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index 3f92176b..ed864f3f 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -1291,6 +1291,7 @@ end local function buildFindPanel(imp, parent, m) imp._findThumbFetched = false + imp._findStatsFetched = false imp:_ensureFind() imp:_ensureMods() local ModIndex = require("src.mods.ModIndex") @@ -1437,12 +1438,13 @@ local function buildFindPanel(imp, parent, m) local btnH = math.ceil(textHeight(chipSize)) + 14 for _, entry in ipairs(rows) do local action, note = findActionFor(entry, installed[entry.id]) - -- Feed-published release stats (downloads, first/last release date) in - -- the same gold line the MODS tab uses; absent until a feed carries them. + -- Release stats for the row: feed-published when the feed carries + -- them, otherwise fetched from the mod's GitHub repo (one per frame, + -- cached six hours) exactly like the MODS tab does. + local stats = imp:_findStats(entry) local statsLine - if entry.downloads ~= nil or entry.first_release or entry.last_release then - statsLine = ModUpdate.statsLine(entry.downloads, - entry.first_release, entry.last_release) + if stats and (stats.total ~= nil or stats.first or stats.latest) then + statsLine = ModUpdate.statsLine(stats.total, stats.first, stats.latest) end local bodyH = math.ceil(textHeight(titleSize)) diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 429ceb3e..364105d0 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -3025,6 +3025,44 @@ function RomImporter:_findThumb(entry) return ok and image or nil end +-- Release stats for a FIND MODS row, resolved the same way the MODS tab +-- does it: the mod's own GitHub releases through ModUpdate's cached fetch, +-- so an installed mod's repo is instant and every result lands in +-- options.modUpdateCache for six hours. A feed that publishes stats wins +-- outright (fresher, zero network); otherwise the repo is fetched, one +-- entry per frame so opening the tab cannot stall for the whole listing. +-- The result is memoized per id for the session; a repo with no releases +-- or a failed fetch resolves to an empty table so it is tried once. +function RomImporter:_findStats(entry) + self._findStats = self._findStats or {} + local cached = self._findStats[entry.id] + if cached then return cached end + if entry.downloads ~= nil or entry.first_release or entry.last_release then + cached = { total = entry.downloads, first = entry.first_release, + latest = entry.last_release, done = true } + self._findStats[entry.id] = cached + return cached + end + if self._findStatsFetched then return nil end -- budget spent this frame + if not entry.github or entry.github == "" then + cached = { done = true } + self._findStats[entry.id] = cached + return cached + end + self._findStatsFetched = true + local ModUpdate = require("src.mods.ModUpdate") + local ok, releases = pcall(function() + local list, err = ModUpdate.fetchReleases(entry.github, entry.id, {}) + if not list then error(tostring(err), 0) end + return list + end) + local stats = ok and ModUpdate.statsForReleases(releases) or nil + cached = { total = stats and stats.total, first = stats and stats.first, + latest = stats and stats.latest, done = true } + self._findStats[entry.id] = cached + return cached +end + -- Open the "add an index" text prompt. Deliberately a typed URL rather than a -- picked-from-a-list affair: there is no blessed index, and presenting one -- would make the launcher's choice look like an endorsement. diff --git a/src/mods/ModUpdate.lua b/src/mods/ModUpdate.lua index 774e1a6c..d9051663 100644 --- a/src/mods/ModUpdate.lua +++ b/src/mods/ModUpdate.lua @@ -229,6 +229,20 @@ function ModUpdate.releaseDates(releases) return { first = first, latest = latest } end +-- One resolver over a release list: { total, first, latest } or nil when +-- the list carries neither counts nor dates. The FIND MODS rows use this +-- on the repo's fetched releases, the same source the MODS tab trusts. +function ModUpdate.statsForReleases(releases) + local dl = ModUpdate.totalDownloads(releases) + local d = ModUpdate.releaseDates(releases) + if not dl and not d then return nil end + return { + total = dl and dl.total or nil, + first = d and d.first or nil, + latest = d and d.latest or nil, + } +end + -- Thousands-separated count for the launcher ("12,345"), plain for small -- numbers. Never throws; garbage in, "0" out. function ModUpdate.formatCount(n) diff --git a/tests/engine/mod_update_tests.lua b/tests/engine/mod_update_tests.lua index 1e3bb9c3..d6ff3239 100644 --- a/tests/engine/mod_update_tests.lua +++ b/tests/engine/mod_update_tests.lua @@ -253,4 +253,23 @@ do HostShell.canFetch, HostShell.httpGet = realCanFetch, realHttpGet end +-- statsForReleases: one resolver over a release list, the FIND MODS path +do + local stats = ModUpdate.statsForReleases({ + { version = "1.0.0", downloads = 41, published = "2024-05-31" }, + { version = "1.1.0", downloads = 9, published = "2025-11-02" }, + }) + eq(stats.total, 50, "total downloads across releases") + eq(stats.first, "2024-05-31", "first release date") + eq(stats.latest, "2025-11-02", "latest release date") + check(ModUpdate.statsForReleases({ { version = "1.0.0" } }) == nil, + "a list with neither counts nor dates resolves to nil") + check(ModUpdate.statsForReleases(nil) == nil, "nil resolves to nil") + local datesOnly = ModUpdate.statsForReleases({ + { version = "1.0.0", published = "2024-05-31" }, + }) + eq(datesOnly.total, nil, "dates without counts keep total nil") + eq(datesOnly.first, "2024-05-31", "but keep the date") +end + print("ok mod_update_tests")