diff --git a/docs/launcher.md b/docs/launcher.md index dde41189..065d40d0 100644 --- a/docs/launcher.md +++ b/docs/launcher.md @@ -153,6 +153,17 @@ before `Game:load`, so **it never loads a mod's entry chunk**; only - `LauncherMods.uninstall(id)` removes `mods//` and clears `options.mods[id]` so a later reinstall starts from the loader's default (enabled). The mods panel Delete control calls this and re-derives the list. +- A mod that declares `github` shows its total GitHub downloads (every + release's summed asset `download_count`, from the same cached release + fetch the update check uses) as a highlighted body line like "12,345 + downloads across all releases - Released 2024-05-31 - Updated 2026-07-01" + (first and latest `published_at`). Old cache entries written before the + counts existed show no line rather than a wrong zero; a manual check + refreshes them. +- The MODS panel sorts its rows by Name, Popularity (downloads), + Release date (first release), or Last updated, chosen by chips under the + header and persisted in `options.modSort`. Mods without release data + (no `github` field, or a stale cache) sink to the bottom of data sorts. ## Import / Export save diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index 04402f42..94506d86 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -1012,6 +1012,7 @@ end local function buildModsPanel(imp, parent, m) imp:_ensureMods() + local ModUpdate = require("src.mods.ModUpdate") local mods = imp.mods or {} local enabledCount = 0 for _, mod in ipairs(mods) do @@ -1060,6 +1061,76 @@ local function buildModsPanel(imp, parent, m) return end + -- Sort row: Name / Popularity / Release date / Last updated. The choice + -- persists in options.modSort; data-less mods (no github field, or a + -- cache that predates the feature) sink to the bottom of data sorts. + 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 = "mod-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(mods) do sorted[i] = v end + table.sort(sorted, function(a, b) + local function value(mod) + if sortKey == "name" then return (mod.name or ""):lower() end + local info = mod.github and mod.github ~= "" and imp:_modUpdateInfo(mod.id) + if sortKey == "popularity" then + return info and info.downloads and info.downloads.total or -1 + end + local date = info and info.dates + if sortKey == "release" then + return date and date.first or "0000-00-00" + end + return date and date.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.name or ""):lower() < (b.name or ""):lower() + end) + mods = sorted + -- Explicit column widths AND heights: a flex-grown container collapses -- its children's layout in this engine, and card auto-height came up -- short on some displays, dropping the bottom action row out of the card. @@ -1091,6 +1162,19 @@ local function buildModsPanel(imp, parent, m) elseif mod.github and mod.github ~= "" then checkLine, checkCol = Strings("Not checked for updates yet"), "warn" end + -- Total downloads plus first/latest release dates, all from the same + -- cached release fetch. Only shown once that data actually carries + -- counts, so a pre-downloads cache entry costs the line, not a wrong "0". + local dlLine + if info and info.downloads then + local formatted = ModUpdate.formatCount(info.downloads.total) + if info.dates then + dlLine = Strings("%s downloads across all releases - Released %s - Updated %s", + formatted, info.dates.first, info.dates.latest) + else + dlLine = Strings("%s downloads across all releases", formatted) + end + end -- measure the body: name (with the badge beside it only when it fits), -- version, check line, wrapped description @@ -1104,6 +1188,9 @@ local function buildModsPanel(imp, parent, m) if checkLine then bodyH = bodyH + 4 + wrapHeight(smallSize, checkLine, bodyW) end + if dlLine then + bodyH = bodyH + 4 + wrapHeight(smallSize, dlLine, bodyW) + end if mod.description ~= "" then bodyH = bodyH + 4 + wrapHeight(smallSize, mod.description, bodyW) end @@ -1155,6 +1242,9 @@ local function buildModsPanel(imp, parent, m) if checkLine then label(body, checkLine, smallSize, C(checkCol), { width = "100%" }) end + if dlLine then + label(body, dlLine, smallSize, C("gold"), { width = "100%" }) + end if mod.description ~= "" then label(body, mod.description, smallSize, C("detail"), { width = "100%" }) end diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 99c0e782..429ceb3e 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -2593,6 +2593,8 @@ function RomImporter:_syncModUpdateInfo(force) latest = best and best.version or nil, best = best, releases = packed.releases, + downloads = ModUpdate.totalDownloads(packed.releases), + dates = ModUpdate.releaseDates(packed.releases), err = nil, checkedAt = packed.checkedAt or os.time(), } @@ -2722,6 +2724,8 @@ function RomImporter:_modGithubAction(id, action) self.modUpdateInfo[row.id] = { status = status, latest = best and best.version, best = best, releases = releases, + downloads = ModUpdate.totalDownloads(releases), + dates = ModUpdate.releaseDates(releases), } self._modVersions = { id = row.id, name = row.name, current = row.version, @@ -2765,6 +2769,8 @@ function RomImporter:_modGithubAction(id, action) self.modUpdateInfo[row.id] = { status = status, latest = best and best.version, best = best, releases = releases, checkedAt = os.time(), + downloads = ModUpdate.totalDownloads(releases), + dates = ModUpdate.releaseDates(releases), } if status == "available" and best then self.modNotice = { ok = true, diff --git a/src/mods/ModUpdate.lua b/src/mods/ModUpdate.lua index ce69328b..ff872232 100644 --- a/src/mods/ModUpdate.lua +++ b/src/mods/ModUpdate.lua @@ -102,7 +102,9 @@ function ModUpdate.previewLine(text, maxChars) return s end --- Decode one GitHub release object into { version, tag, zip, prerelease, body }. +-- Decode one GitHub release object into { version, tag, zip, prerelease, +-- name, body, downloads }. `downloads` is the sum of every asset's +-- download_count, GitHub's own measure of a release's downloads. function ModUpdate.parseRelease(doc, modId) if type(doc) ~= "table" or not doc.tag_name then return nil, "no tag_name in release" @@ -114,6 +116,13 @@ function ModUpdate.parseRelease(doc, modId) local triple = version:match("^(%d+%.%d+%.%d+)") local zip = ModUpdate.pickZipAsset(doc.assets, modId, triple) local body = type(doc.body) == "string" and doc.body or "" + local downloads = 0 + if type(doc.assets) == "table" then + for _, a in ipairs(doc.assets) do + local d = type(a) == "table" and tonumber(a.download_count) + if d and d > 0 then downloads = downloads + d end + end + end return { version = triple, tag = tostring(doc.tag_name), @@ -121,6 +130,8 @@ function ModUpdate.parseRelease(doc, modId) prerelease = doc.prerelease == true, name = type(doc.name) == "string" and doc.name or triple, body = body, + downloads = downloads, + published = (doc.published_at or doc.created_at or ""):match("^(%d+%-%d+%-%d+)"), } end @@ -154,7 +165,7 @@ function ModUpdate.parseReleases(jsonText, modId, Json) end function ModUpdate.apiReleasesUrl(repo) - return "https://api.github.com/repos/" .. repo .. "/releases?per_page=30" + return "https://api.github.com/repos/" .. repo .. "/releases?per_page=100" end function ModUpdate.apiLatestUrl(repo) @@ -180,6 +191,52 @@ function ModUpdate.pickBest(releases) return releases[1] end +-- Sum of per-release download counts. Returns nil when no release carries +-- the field (a cache entry written before downloads existed), else +-- { total = , releases = } so a caller can tell a +-- real "0 downloads" apart from "no data yet". +function ModUpdate.totalDownloads(releases) + if type(releases) ~= "table" or #releases == 0 then return nil end + local total, hasData = 0, false + for _, rel in ipairs(releases) do + local d = type(rel) == "table" and tonumber(rel.downloads) + if d then + hasData = true + total = total + d + end + end + if not hasData then return nil end + return { total = total, releases = #releases } +end + +-- First and latest release dates ("YYYY-MM-DD", ISO strings compare in +-- calendar order). Returns nil when no release carries a date -- same +-- "no data yet" rule as totalDownloads. +function ModUpdate.releaseDates(releases) + if type(releases) ~= "table" or #releases == 0 then return nil end + local first, latest, has = nil, nil, false + for _, rel in ipairs(releases) do + local p = type(rel) == "table" and rel.published + if type(p) == "string" and p ~= "" then + has = true + if not first or p < first then first = p end + if not latest or p > latest then latest = p end + end + end + if not has then return nil end + return { first = first, latest = latest } +end + +-- Thousands-separated count for the launcher ("12,345"), plain for small +-- numbers. Never throws; garbage in, "0" out. +function ModUpdate.formatCount(n) + n = tonumber(n) + if not n or n ~= n or n < 0 then return "0" end + local s = tostring(math.floor(n)) + s = s:reverse():gsub("(%d%d%d)", "%1,"):reverse() + return (s:gsub("^,", "")) +end + -- ------- cache (options.modUpdateCache[repo]) local function cacheStore() @@ -210,6 +267,22 @@ function ModUpdate.cacheFresh(entry, now, ttl) and (now - entry.checkedAt) < ttl end +-- A cache entry written before download counts existed carries no +-- `downloads` on any release. It is provably stale -- parseRelease always +-- sets the field now -- so treat it as expired: the next fetch rewrites +-- the entry in the current format, one refetch per repo, and the launcher's +-- download line stops hiding behind an old cache. +function ModUpdate.cacheUsable(cached) + if type(cached) ~= "table" or type(cached.releases) ~= "table" then + return false + end + if #cached.releases == 0 then return true end + for _, rel in ipairs(cached.releases) do + if tonumber(rel.downloads) then return true end + end + return false +end + function ModUpdate.writeCache(repo, releases) if type(repo) ~= "string" or repo == "" then return false end local ok = pcall(function() @@ -226,6 +299,8 @@ function ModUpdate.writeCache(repo, releases) name = rel.name, prerelease = rel.prerelease == true, body = type(rel.body) == "string" and rel.body or "", + downloads = tonumber(rel.downloads) or 0, + published = rel.published, zip = rel.zip and { name = rel.zip.name, url = rel.zip.url, @@ -280,7 +355,7 @@ function ModUpdate.fetchReleases(repo, modId, opts) end if not opts.force then local cached = ModUpdate.readCache(repo) - if ModUpdate.cacheFresh(cached) then + if ModUpdate.cacheFresh(cached) and ModUpdate.cacheUsable(cached) then return cached.releases, nil, { fromCache = true } end end diff --git a/tests/engine/mod_update_tests.lua b/tests/engine/mod_update_tests.lua index 759d2f18..ae7c2c00 100644 --- a/tests/engine/mod_update_tests.lua +++ b/tests/engine/mod_update_tests.lua @@ -108,4 +108,133 @@ do check(preview:find("Changes", 1, true), "previewLine keeps heading text") end +-- downloads: per-release sum over every asset's download_count +do + local body = Json.encode({ + { + tag_name = "v1.2.0", + assets = { + { name = "demo-1.2.0.zip", browser_download_url = "https://x/d.zip", + size = 99, download_count = 41 }, + { name = "demo-1.2.0.sha256", browser_download_url = "https://x/d.sha", + download_count = 9 }, + }, + }, + }) + local list = ModUpdate.parseReleases(body, "demo") + eq(list[1].downloads, 50, "downloads sums every asset, not just the zip") +end + +-- published: the release date is kept as an ISO day +do + local body = Json.encode({ + { tag_name = "v1.2.0", published_at = "2025-04-13T09:24:00Z", + assets = { { name = "demo-1.2.0.zip", + browser_download_url = "https://x/d.zip" } } }, + }) + local list = ModUpdate.parseReleases(body, "demo") + eq(list[1].published, "2025-04-13", "published_at is reduced to the day") + local noDate = ModUpdate.parseReleases(Json.encode({ + { tag_name = "v1.0.0", + assets = { { name = "demo-1.0.0.zip", + browser_download_url = "https://x/d.zip" } } }, + }), "demo") + check(noDate[1].published == nil, "missing dates stay nil, never throw") +end + +-- releaseDates: first and latest across releases +do + local d = ModUpdate.releaseDates({ + { version = "1.0.0", published = "2024-05-31" }, + { version = "1.2.0", published = "2025-11-02" }, + { version = "1.1.0", published = "2025-01-15" }, + }) + eq(d.first, "2024-05-31", "first is the oldest release date") + eq(d.latest, "2025-11-02", "latest is the newest release date") + check(ModUpdate.releaseDates({ { version = "1.0.0" } }) == nil, + "releases without dates report no data") + check(ModUpdate.releaseDates({}) == nil, "empty list reports no data") + check(ModUpdate.releaseDates(nil) == nil, "nil list reports no data") +end + +-- totalDownloads: totals across releases; nil until data actually exists +do + local new = { + { version = "1.0.0", downloads = 41 }, + { version = "1.1.0", downloads = 9 }, + } + local dl = ModUpdate.totalDownloads(new) + eq(dl.total, 50, "totals across releases") + eq(dl.releases, 2, "counts the releases") + dl = ModUpdate.totalDownloads({ { version = "1.0.0", downloads = 0 } }) + eq(dl.total, 0, "a real zero stays zero") + dl = ModUpdate.totalDownloads({ { version = "1.0.0" } }) + check(dl == nil, "pre-downloads cache rows report no data") + check(ModUpdate.totalDownloads({}) == nil, "empty list reports no data") + check(ModUpdate.totalDownloads(nil) == nil, "nil list reports no data") +end + +-- formatCount: thousands separators, never throws +eq(ModUpdate.formatCount(0), "0", "zero formats plain") +eq(ModUpdate.formatCount(999), "999", "below 1000 formats plain") +eq(ModUpdate.formatCount(1000), "1,000", "1000 gets a separator") +eq(ModUpdate.formatCount(1234567), "1,234,567", "large counts group by 3") +eq(ModUpdate.formatCount("12345"), "12,345", "numeric strings are accepted") +eq(ModUpdate.formatCount(nil), "0", "nil formats as zero") +eq(ModUpdate.formatCount("garbage"), "0", "garbage formats as zero") + +-- cacheUsable: a cache entry from before downloads existed must not be +-- trusted, everything current is +do + local old = { checkedAt = os.time(), + releases = { { version = "1.0.0", tag = "v1.0.0" } } } + check(not ModUpdate.cacheUsable(old), "pre-downloads cache is unusable") + local zero = { checkedAt = os.time(), + releases = { { version = "1.0.0", downloads = 0 } } } + check(ModUpdate.cacheUsable(zero), "current cache is usable even at zero") + check(ModUpdate.cacheUsable({ checkedAt = os.time(), releases = {} }), + "empty release list stays usable") + check(not ModUpdate.cacheUsable(nil), "nil cache is unusable") + check(not ModUpdate.cacheUsable({}), "cache without releases is unusable") +end + +-- fetchReleases: an old-format cache entry is refetched once and rewritten +-- in the current format; a current one is served untouched +do + local HostShell = require("src.core.HostShell") + local realRead, realWrite = ModUpdate.readCache, ModUpdate.writeCache + local realCanFetch, realHttpGet = HostShell.canFetch, HostShell.httpGet + local cached, fetched + ModUpdate.readCache = function() return cached end + ModUpdate.writeCache = function(repo, releases) + fetched = releases + return true + end + HostShell.canFetch = function() return true end + HostShell.httpGet = function() + return Json.encode({ { + tag_name = "v1.0.0", + assets = { { name = "demo-1.0.0.zip", + browser_download_url = "https://x/d.zip", download_count = 7 } }, + } }) + end + + cached = { checkedAt = os.time(), + releases = { { version = "1.0.0", tag = "v1.0.0" } } } + fetched = nil + local list = ModUpdate.fetchReleases("acme/mod", "demo") + check(fetched ~= nil, "old-format cache triggers a refetch") + check(list[1].downloads == 7, "refetched release carries downloads") + + cached = { checkedAt = os.time(), + releases = { { version = "1.0.0", tag = "v1.0.0", downloads = 0 } } } + fetched = nil + list = ModUpdate.fetchReleases("acme/mod", "demo") + check(fetched == nil, "current cache is served without refetch") + check(list[1].downloads == 0, "cached zero stays zero") + + ModUpdate.readCache, ModUpdate.writeCache = realRead, realWrite + HostShell.canFetch, HostShell.httpGet = realCanFetch, realHttpGet +end + print("ok mod_update_tests")