diff --git a/docs/new-features.md b/docs/new-features.md index fa886b6d..725fbc09 100644 --- a/docs/new-features.md +++ b/docs/new-features.md @@ -483,9 +483,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 10401edf..d49e457d 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -1373,9 +1373,11 @@ end local function buildFindPanel(imp, parent, m) imp._findThumbFetched = false + imp._findStatsFetched = false imp:_ensureFind() imp:_ensureMods() local ModIndex = require("src.mods.ModIndex") + local ModUpdate = require("src.mods.ModUpdate") local sources = imp.findSources or {} local rows = imp:_findRows() local total = #((imp.findIndex and imp.findIndex.mods) or {}) @@ -1513,6 +1515,80 @@ local function buildFindPanel(imp, parent, m) return end + -- Sort row: Name / Popularity / Release date / Last updated, the same + -- options the MODS tab offers, sharing its persisted choice + -- (options.modSort). Data comes from the same _findStats resolution the + -- cards use (feed-published, else the repo fetch); rows whose stats have + -- not resolved yet sink to the bottom of data sorts and rise as the + -- one-per-frame fetches complete. + local sortKey = imp.modSort or "name" + if imp.modSort == nil then + local ok, opts = pcall(require("src.core.SaveData").loadOptions) + if ok and type(opts) == "table" and type(opts.modSort) == "string" then + sortKey = opts.modSort + imp.modSort = sortKey + end + end + local sortRow = mk({ parent = parent, width = "100%", + positioning = "flex", flexDirection = "horizontal", + flexWrap = "wrap", alignItems = "center", gap = 6 * m.s }) + label(sortRow, Strings("Sort:"), 11 * m.s + 2, C("detail"), { textWrap = false }) + local sorts = { + { key = "name", label = Strings("Name") }, + { key = "popularity", label = Strings("Popularity") }, + { key = "release", label = Strings("Release date") }, + { key = "updated", label = Strings("Last updated") }, + } + for _, s in ipairs(sorts) do + local active = sortKey == s.key + local key = "find-sort-" .. s.key + mk({ + parent = sortRow, text = s.label, + textColor = active and C("green") + or (imp._hot[key] and C("white") or C("detail")), + textSize = 11 * m.s + 2, textAlign = "center-center", autoScaleText = false, + backgroundColor = active and C("green", 0.18) or C("border", 0.10), + border = 1, + borderColor = active and C("green", 0.6) or C("border", 0.35), + cornerRadius = 999, + padding = { horizontal = 10, vertical = 4 }, + onEvent = handler(imp, key, function() + imp.modSort = s.key + pcall(function() + local SaveData = require("src.core.SaveData") + local opts = SaveData.loadOptions() + opts.modSort = s.key + SaveData.saveOptions(opts) + end) + end), + }) + end + + local sorted = {} + for i, v in ipairs(rows) do sorted[i] = v end + table.sort(sorted, function(a, b) + local function value(entry) + if sortKey == "name" then + return (entry.title or entry.id or ""):lower() + end + local stats = imp:_findStats(entry) + if sortKey == "popularity" then + return stats and stats.total or -1 + end + if sortKey == "release" then + return stats and stats.first or "0000-00-00" + end + return stats and stats.latest or "0000-00-00" + end + local va, vb = value(a), value(b) + if va ~= vb then + if sortKey == "name" then return va < vb end + return va > vb -- data sorts newest / most popular first + end + return (a.title or a.id or ""):lower() < (b.title or b.id or ""):lower() + end) + rows = sorted + local installed = imp:_findInstalledMap() local thumbW = 64 * m.s -- Explicit measured widths AND heights, same reasoning as the mods card: @@ -1526,12 +1602,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 0d174729..74e863b6 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -3075,6 +3075,56 @@ 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._findStatsCache = self._findStatsCache or {} + local cached = self._findStatsCache[entry.id] + if cached then + if cached.done or (cached.retryAt and os.time() < cached.retryAt) then + return cached + end + self._findStatsCache[entry.id] = nil -- retry window open, refetch + 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._findStatsCache[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._findStatsCache[entry.id] = cached + return cached + end + self._findStatsFetched = true + local ModUpdate = require("src.mods.ModUpdate") + local list, fetchErr + local ok = pcall(function() + list, fetchErr = ModUpdate.fetchReleases(entry.github, entry.id, {}) + end) + local stats = list and ModUpdate.statsForReleases(list) or nil + if stats then + cached = { total = stats.total, first = stats.first, + latest = stats.latest, done = true } + else + -- A repo that does not exist is permanent; every other failure (the + -- hourly API rate limit, a hiccup) is retried in a minute so rows can + -- recover without restarting the launcher. + local permanent = tostring(fetchErr):find("Not Found", 1, true) ~= nil + cached = { done = permanent, retryAt = os.time() + 60 } + end + self._findStatsCache[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")