mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 00:10:56 +02:00
b692474e24
A mod index can now publish three optional per-entry fields -- downloads (total across every release), first_release and last_release (ISO days) -- which the FIND MODS listing shows in the same gold line the MODS tab uses. The fields are additive by design: feeds that carry them stay readable by every build that predates them (schema_version stays 1), and feeds that do not render exactly as before. ModUpdate.statsLine builds the shared line; the MODS tab reuses it. Parser, formatting, and parse coverage are tested.
614 lines
22 KiB
Lua
614 lines
22 KiB
Lua
-- Community mod index consumer (the "Find mods" launcher tab).
|
|
--
|
|
-- An index is metadata only: a published index.json feed listing mods that
|
|
-- live in their authors' own repos. Nothing here clones or vendors the index
|
|
-- repo -- the feed is the whole contract, and every install still goes through
|
|
-- the same LauncherMods.installZip path an "Import mod .zip" does, so a listing
|
|
-- buys a mod no trust it would not otherwise have.
|
|
--
|
|
-- Shape of the split mirrors src/mods/ModUpdate.lua, which this borrows its
|
|
-- host I/O from: everything above "host I/O" is pure (no love, no filesystem,
|
|
-- no network) so the engine tier can table-drive it, and the fetch/cache half
|
|
-- reaches for curl and options.lua.
|
|
--
|
|
-- Sources are never added automatically. options.modIndexes is a player-built
|
|
-- list -- adding an index is a deliberate act of trusting whoever publishes it,
|
|
-- so the launcher ships with none and asks.
|
|
--
|
|
-- schema_version is a hard gate, not a hint: a bumped feed may reuse a field
|
|
-- name for something else, so an unknown version is refused outright rather
|
|
-- than parsed hopefully.
|
|
|
|
local ModIndex = {}
|
|
|
|
-- The feed is rebuilt on every push and refreshed nightly, so a day-old copy
|
|
-- is the worst a cached listing can be. ModUpdate's six hours is tuned for a
|
|
-- single repo's releases; a whole index is heavier and changes more slowly.
|
|
ModIndex.CACHE_TTL = 24 * 60 * 60
|
|
ModIndex.SCHEMA_VERSION = 1
|
|
|
|
-- ------- pure: source resolution
|
|
|
|
local function trim(s)
|
|
return (tostring(s):gsub("^%s+", ""):gsub("%s+$", ""))
|
|
end
|
|
|
|
-- Split "owner/repo" out of the several ways a player can name a GitHub repo.
|
|
local function githubSlug(url)
|
|
local owner, repo = url:match("^https?://github%.com/([%w%-%.]+)/([%w%-%.]+)")
|
|
if not owner then
|
|
owner, repo = url:match("^([%w%-%.]+)/([%w%-%.]+)$")
|
|
end
|
|
if not owner then return nil end
|
|
repo = repo:gsub("%.git$", "")
|
|
return owner, repo
|
|
end
|
|
|
|
-- resolveSource(input) -> { feed, base, fallback, label } | nil, err
|
|
--
|
|
-- Players paste whichever URL they happened to have, so all four shapes of the
|
|
-- same index resolve to one source: the Pages root, the feed file itself, the
|
|
-- GitHub repo page, or a bare "owner/repo". `base` is what relative thumbnail
|
|
-- and description_url paths resolve against, and it always keeps its trailing
|
|
-- slash so joinUrl can stay a concatenation.
|
|
--
|
|
-- The raw.githubusercontent fallback exists because Pages deploys lag a push
|
|
-- by a minute or two; it is only ever consulted when the feed fetch fails.
|
|
function ModIndex.resolveSource(input)
|
|
if type(input) ~= "string" then return nil, "missing index URL" end
|
|
local url = trim(input)
|
|
if url == "" then return nil, "missing index URL" end
|
|
|
|
local owner, repo = githubSlug(url)
|
|
if owner then
|
|
return {
|
|
feed = ("https://%s.github.io/%s/data/index.json"):format(owner, repo),
|
|
base = ("https://%s.github.io/%s/"):format(owner, repo),
|
|
fallback = ("https://raw.githubusercontent.com/%s/%s/main/site/data/index.json")
|
|
:format(owner, repo),
|
|
label = owner .. "/" .. repo,
|
|
}
|
|
end
|
|
|
|
if not url:match("^https?://") then
|
|
return nil, "index must be an http(s) URL or owner/repo"
|
|
end
|
|
|
|
-- A feed URL names the file; the Pages root is what is left once the
|
|
-- "data/index.json" tail comes off (any other .json keeps only its folder).
|
|
if url:match("%.json$") then
|
|
local base = url:match("^(.*/)data/index%.json$") or url:match("^(.*/)")
|
|
return { feed = url, base = base or url, label = ModIndex.labelFor(base or url) }
|
|
end
|
|
|
|
local base = url:match("/$") and url or (url .. "/")
|
|
return {
|
|
feed = base .. "data/index.json",
|
|
base = base,
|
|
label = ModIndex.labelFor(base),
|
|
}
|
|
end
|
|
|
|
-- A short human name for a source row: "owner/repo" for a Pages host, else the
|
|
-- host plus first path segment. Only ever cosmetic.
|
|
function ModIndex.labelFor(url)
|
|
url = tostring(url or "")
|
|
local owner, repo = url:match("^https?://([%w%-%.]+)%.github%.io/([%w%-%.]+)/")
|
|
if owner then return owner .. "/" .. repo end
|
|
local host, first = url:match("^https?://([^/]+)/([^/]*)")
|
|
if host and first and first ~= "" then return host .. "/" .. first end
|
|
return host or url
|
|
end
|
|
|
|
-- joinUrl(base, rel) -> absolute URL | nil
|
|
-- Relative feed paths resolve against the Pages root; an already-absolute one
|
|
-- is handed back untouched (the schema allows either), and anything else --
|
|
-- nil, "", a non-string -- is simply absent rather than an error, because a
|
|
-- missing thumbnail is not a broken index.
|
|
function ModIndex.joinUrl(base, rel)
|
|
if type(rel) ~= "string" or rel == "" then return nil end
|
|
if rel:match("^https?://") then return rel end
|
|
if type(base) ~= "string" or base == "" then return nil end
|
|
if not base:match("/$") then base = base .. "/" end
|
|
return base .. (rel:gsub("^/", ""))
|
|
end
|
|
|
|
-- ------- pure: feed parsing
|
|
|
|
local function str(v)
|
|
return type(v) == "string" and v or nil
|
|
end
|
|
|
|
local function strArray(v)
|
|
local out = {}
|
|
if type(v) == "table" then
|
|
for _, entry in ipairs(v) do
|
|
if type(entry) == "string" then out[#out + 1] = entry end
|
|
end
|
|
end
|
|
return out
|
|
end
|
|
|
|
-- Decode one release blob from the feed's `latest` field into the same shape
|
|
-- ModUpdate.parseRelease produces, so LauncherMods.installFromRelease takes it
|
|
-- without a translation layer.
|
|
local function parseLatest(raw)
|
|
if type(raw) ~= "table" then return nil end
|
|
local zip = nil
|
|
if type(raw.zip) == "table" and str(raw.zip.url) then
|
|
zip = { name = str(raw.zip.name), url = raw.zip.url,
|
|
size = tonumber(raw.zip.size) }
|
|
end
|
|
return {
|
|
version = str(raw.version),
|
|
tag = str(raw.tag),
|
|
name = str(raw.name),
|
|
prerelease = raw.prerelease == true,
|
|
published_at = str(raw.published_at),
|
|
zip = zip,
|
|
}
|
|
end
|
|
|
|
local function parseEntry(raw)
|
|
if type(raw) ~= "table" or not str(raw.id) then return nil end
|
|
return {
|
|
folder = str(raw.folder),
|
|
id = raw.id,
|
|
title = str(raw.title) or raw.id,
|
|
author = str(raw.author),
|
|
version = str(raw.version),
|
|
summary = str(raw.summary) or "",
|
|
categories = strArray(raw.categories),
|
|
tags = strArray(raw.tags),
|
|
license = str(raw.license),
|
|
repo = str(raw.repo),
|
|
github = str(raw.github),
|
|
downloadURL = str(raw.downloadURL),
|
|
api = tonumber(raw.api),
|
|
game_version = str(raw.game_version),
|
|
profile = str(raw.profile),
|
|
affects_link = raw.affects_link == true,
|
|
experimental = raw.experimental == true,
|
|
permissions = strArray(raw.permissions),
|
|
dependencies = raw.dependencies,
|
|
conflicts = raw.conflicts,
|
|
thumbnail = str(raw.thumbnail),
|
|
description_url = str(raw.description_url),
|
|
-- Optional release stats a feed author can publish: total downloads
|
|
-- across all releases plus first/last release dates. Additive-only,
|
|
-- so a feed that carries them stays readable by every build that
|
|
-- predates them (and one that does not still renders fine here).
|
|
downloads = tonumber(raw.downloads),
|
|
first_release = str(raw.first_release),
|
|
last_release = str(raw.last_release),
|
|
latest = parseLatest(raw.latest),
|
|
update_check = str(raw.update_check) or "pending",
|
|
}
|
|
end
|
|
|
|
-- parse(jsonText [, Json]) -> { schemaVersion, generatedAt, categories, mods }
|
|
-- | nil, err
|
|
-- Never throws: a truncated download, an HTML error page, or a feed from a
|
|
-- future schema all come back as a message the panel can print.
|
|
function ModIndex.parse(jsonText, Json)
|
|
local ok, result, err = pcall(function()
|
|
Json = Json or require("src.link.Json")
|
|
local doc, decodeErr = Json.decode(jsonText)
|
|
if type(doc) ~= "table" then
|
|
return nil, decodeErr or "index.json is not an object"
|
|
end
|
|
local schema = tonumber(doc.schema_version)
|
|
if schema == nil then
|
|
return nil, "index.json has no schema_version"
|
|
end
|
|
if schema ~= ModIndex.SCHEMA_VERSION then
|
|
return nil, ("index schema %d is not supported (this build reads %d)")
|
|
:format(schema, ModIndex.SCHEMA_VERSION)
|
|
end
|
|
if type(doc.mods) ~= "table" then
|
|
return nil, "index.json has no mods array"
|
|
end
|
|
local mods = {}
|
|
for _, raw in ipairs(doc.mods) do
|
|
local entry = parseEntry(raw)
|
|
if entry then mods[#mods + 1] = entry end
|
|
end
|
|
return {
|
|
schemaVersion = schema,
|
|
generatedAt = str(doc.generated_at),
|
|
categories = strArray(doc.categories),
|
|
mods = mods,
|
|
}
|
|
end)
|
|
if not ok then return nil, "could not read the index: " .. tostring(result) end
|
|
return result, err
|
|
end
|
|
|
|
-- ------- pure: install resolution
|
|
|
|
-- installUrl(entry) -> url, kind | nil, reason
|
|
--
|
|
-- The same order the engine's zip import already implies: a verified release
|
|
-- asset first, then the author's fixed downloadURL. A GitHub source-archive
|
|
-- URL is never invented -- codeload gives you the repo, not the built mod, and
|
|
-- the folder layout would be wrong even when the download succeeds.
|
|
function ModIndex.installUrl(entry)
|
|
if type(entry) ~= "table" then return nil, "no entry" end
|
|
if entry.update_check == "ok" and entry.latest and entry.latest.zip
|
|
and entry.latest.zip.url then
|
|
return entry.latest.zip.url, "release"
|
|
end
|
|
if entry.downloadURL and entry.downloadURL ~= "" then
|
|
return entry.downloadURL, "download"
|
|
end
|
|
if entry.update_check == "off" then
|
|
return nil, "the author does not publish installable releases"
|
|
end
|
|
if entry.update_check == "no installable release" then
|
|
return nil, "no release with a .zip asset yet"
|
|
end
|
|
if type(entry.update_check) == "string"
|
|
and entry.update_check:match("^error") then
|
|
return nil, entry.update_check
|
|
end
|
|
return nil, "nothing installable listed"
|
|
end
|
|
|
|
function ModIndex.canInstall(entry)
|
|
return ModIndex.installUrl(entry) ~= nil
|
|
end
|
|
|
|
-- The version to show on a card: the release the index resolved when it could
|
|
-- reach GitHub, else whatever meta.json declared.
|
|
function ModIndex.displayVersion(entry)
|
|
if type(entry) ~= "table" then return "?" end
|
|
if entry.update_check == "ok" and entry.latest and entry.latest.version then
|
|
return entry.latest.version
|
|
end
|
|
return entry.version or "?"
|
|
end
|
|
|
|
-- The release table LauncherMods.installFromRelease wants. A downloadURL
|
|
-- entry has no release behind it, so one is synthesised around the URL; the
|
|
-- installer still validates the manifest inside and still refuses a zip whose
|
|
-- id is not the one being installed.
|
|
function ModIndex.releaseFor(entry)
|
|
local url, kind = ModIndex.installUrl(entry)
|
|
if not url then return nil, kind end
|
|
if kind == "release" then return entry.latest end
|
|
return {
|
|
version = ModIndex.displayVersion(entry),
|
|
zip = { url = url, name = entry.id .. ".zip" },
|
|
}
|
|
end
|
|
|
|
-- ------- pure: compatibility
|
|
|
|
-- compatIssues(entry, ctx) -> array of { level, text }
|
|
--
|
|
-- Soft gate by design: an index entry is metadata an author wrote, possibly
|
|
-- months ago, and hiding a mod because a range looks wrong is how a working
|
|
-- mod becomes invisible. Everything here warns; the confirm dialog shows the
|
|
-- list and the player decides. ctx carries { modApi, engineVersion,
|
|
-- installed = { id -> version }, enabled = { id -> true } }.
|
|
function ModIndex.compatIssues(entry, ctx)
|
|
local out = {}
|
|
if type(entry) ~= "table" then return out end
|
|
ctx = ctx or {}
|
|
local function warn(text) out[#out + 1] = { level = "warn", text = text } end
|
|
|
|
local modApi = tonumber(ctx.modApi)
|
|
if entry.api and modApi and entry.api > modApi then
|
|
warn(("Needs mod API %d; this build provides %d")
|
|
:format(entry.api, modApi))
|
|
end
|
|
|
|
if entry.game_version and ctx.engineVersion then
|
|
local okSemver, Semver = pcall(require, "src.mods.Semver")
|
|
if okSemver and not Semver.satisfies(ctx.engineVersion, entry.game_version) then
|
|
warn(("Needs engine %s (have %s)")
|
|
:format(entry.game_version, ctx.engineVersion))
|
|
end
|
|
end
|
|
|
|
if entry.profile and entry.profile ~= "content" then
|
|
warn(("Profile '%s' changes engine behaviour beyond content")
|
|
:format(entry.profile))
|
|
end
|
|
if entry.affects_link then
|
|
warn("Changes link play; both sides need the same mods")
|
|
end
|
|
if entry.experimental then
|
|
warn("Marked experimental by its author")
|
|
end
|
|
|
|
for _, name in ipairs(entry.permissions or {}) do
|
|
warn("Requests permission: " .. name)
|
|
end
|
|
|
|
-- dependencies / conflicts arrive as the manifest's own vocabulary: either
|
|
-- an array of "id" / "id@<range>" strings or an id -> range map.
|
|
local installed = ctx.installed or {}
|
|
local function eachSpec(spec, fn)
|
|
if type(spec) ~= "table" then return end
|
|
for k, v in pairs(spec) do
|
|
if type(k) == "number" and type(v) == "string" then
|
|
local id, range = v:match("^([^@]+)@(.+)$")
|
|
fn(id or v, range)
|
|
elseif type(k) == "string" then
|
|
fn(k, type(v) == "string" and v or nil)
|
|
end
|
|
end
|
|
end
|
|
|
|
eachSpec(entry.dependencies, function(id, range)
|
|
if installed[id] == nil then
|
|
warn("Needs " .. id .. (range and (" " .. range) or "") .. " (not installed)")
|
|
end
|
|
end)
|
|
eachSpec(entry.conflicts, function(id)
|
|
if installed[id] ~= nil then
|
|
warn("Conflicts with installed " .. id)
|
|
end
|
|
end)
|
|
|
|
return out
|
|
end
|
|
|
|
-- ------- pure: search / filter
|
|
|
|
local function haystack(entry)
|
|
return (tostring(entry.title or "") .. " " .. tostring(entry.author or "")
|
|
.. " " .. tostring(entry.summary or "") .. " " .. tostring(entry.id or ""))
|
|
:lower()
|
|
end
|
|
|
|
-- matches(entry, query) -> bool. Every whitespace-separated term must appear
|
|
-- somewhere in title / author / summary / id, so typing more narrows.
|
|
function ModIndex.matches(entry, query)
|
|
if type(query) ~= "string" or trim(query) == "" then return true end
|
|
local hay = haystack(entry)
|
|
for term in trim(query):lower():gmatch("%S+") do
|
|
if not hay:find(term, 1, true) then return false end
|
|
end
|
|
return true
|
|
end
|
|
|
|
-- filter(mods, opts) -> a new array. opts = { query, category, tag }.
|
|
-- Category and tag compare case-insensitively; feed order (already sorted by
|
|
-- title) is preserved.
|
|
function ModIndex.filter(mods, opts)
|
|
opts = opts or {}
|
|
local want = opts.category and tostring(opts.category):lower() or nil
|
|
local wantTag = opts.tag and tostring(opts.tag):lower() or nil
|
|
local out = {}
|
|
for _, entry in ipairs(mods or {}) do
|
|
local keep = ModIndex.matches(entry, opts.query)
|
|
if keep and want then
|
|
keep = false
|
|
for _, c in ipairs(entry.categories or {}) do
|
|
if tostring(c):lower() == want then keep = true; break end
|
|
end
|
|
end
|
|
if keep and wantTag then
|
|
keep = false
|
|
for _, t in ipairs(entry.tags or {}) do
|
|
if tostring(t):lower() == wantTag then keep = true; break end
|
|
end
|
|
end
|
|
if keep then out[#out + 1] = entry end
|
|
end
|
|
return out
|
|
end
|
|
|
|
-- Every category actually used by a feed, in the feed's declared order, with
|
|
-- anything an entry names that the header forgot appended. Drives the filter
|
|
-- row without hard-coding the vocabulary.
|
|
function ModIndex.categoriesIn(index)
|
|
local out, seen = {}, {}
|
|
if type(index) ~= "table" then return out end
|
|
local used = {}
|
|
for _, entry in ipairs(index.mods or {}) do
|
|
for _, c in ipairs(entry.categories or {}) do used[c] = true end
|
|
end
|
|
for _, c in ipairs(index.categories or {}) do
|
|
if used[c] and not seen[c] then seen[c] = true; out[#out + 1] = c end
|
|
end
|
|
for _, entry in ipairs(index.mods or {}) do
|
|
for _, c in ipairs(entry.categories or {}) do
|
|
if not seen[c] then seen[c] = true; out[#out + 1] = c end
|
|
end
|
|
end
|
|
return out
|
|
end
|
|
|
|
-- ------- sources (options.modIndexes)
|
|
|
|
local function loadOptions()
|
|
return require("src.core.SaveData").loadOptions()
|
|
end
|
|
|
|
-- The player's index list, normalised. Rows are { url, feed, base, fallback,
|
|
-- label }; `url` is what they typed, kept so the row reads back the way they
|
|
-- entered it.
|
|
function ModIndex.sources()
|
|
local ok, opts = pcall(loadOptions)
|
|
if not ok or type(opts) ~= "table" then return {} end
|
|
local out = {}
|
|
for _, row in ipairs(opts.modIndexes or {}) do
|
|
if type(row) == "table" and type(row.feed) == "string" then
|
|
out[#out + 1] = row
|
|
end
|
|
end
|
|
return out
|
|
end
|
|
|
|
-- addSource(input) -> row | nil, err. Idempotent on the resolved feed URL, so
|
|
-- pasting the repo page and the Pages root in either order adds one source.
|
|
function ModIndex.addSource(input)
|
|
local source, err = ModIndex.resolveSource(input)
|
|
if not source then return nil, err end
|
|
local ok, result, addErr = pcall(function()
|
|
local SaveData = require("src.core.SaveData")
|
|
local opts = loadOptions()
|
|
opts.modIndexes = opts.modIndexes or {}
|
|
for _, row in ipairs(opts.modIndexes) do
|
|
if row.feed == source.feed then
|
|
return nil, "that index is already added"
|
|
end
|
|
end
|
|
source.url = trim(input)
|
|
opts.modIndexes[#opts.modIndexes + 1] = source
|
|
SaveData.saveOptions(opts)
|
|
return source
|
|
end)
|
|
if not ok then return nil, "could not save the index: " .. tostring(result) end
|
|
return result, addErr
|
|
end
|
|
|
|
-- removeSource(feed) -> true | nil, err. Drops the cached listing with it:
|
|
-- keeping a feed's mods around after its source is gone is how a stale card
|
|
-- outlives the index it came from.
|
|
function ModIndex.removeSource(feed)
|
|
if type(feed) ~= "string" or feed == "" then return nil, "missing index" end
|
|
local ok, result = pcall(function()
|
|
local SaveData = require("src.core.SaveData")
|
|
local opts = loadOptions()
|
|
local kept, found = {}, false
|
|
for _, row in ipairs(opts.modIndexes or {}) do
|
|
if row.feed == feed then found = true else kept[#kept + 1] = row end
|
|
end
|
|
if not found then return nil end
|
|
opts.modIndexes = kept
|
|
if type(opts.modIndexCache) == "table" then opts.modIndexCache[feed] = nil end
|
|
SaveData.saveOptions(opts)
|
|
return true
|
|
end)
|
|
if not ok then return nil, tostring(result) end
|
|
if not result then return nil, "that index is not in the list" end
|
|
return true
|
|
end
|
|
|
|
-- ------- cache (options.modIndexCache[feed])
|
|
|
|
function ModIndex.readCache(feed)
|
|
if type(feed) ~= "string" or feed == "" then return nil end
|
|
local ok, opts = pcall(loadOptions)
|
|
if not ok or type(opts) ~= "table" then return nil end
|
|
local entry = opts.modIndexCache and opts.modIndexCache[feed]
|
|
if type(entry) ~= "table" or type(entry.checkedAt) ~= "number" then
|
|
return nil
|
|
end
|
|
if type(entry.mods) ~= "table" then return nil end
|
|
return entry
|
|
end
|
|
|
|
function ModIndex.cacheFresh(entry, now, ttl)
|
|
now = now or os.time()
|
|
ttl = ttl or ModIndex.CACHE_TTL
|
|
return entry ~= nil and type(entry.checkedAt) == "number"
|
|
and (now - entry.checkedAt) < ttl
|
|
end
|
|
|
|
function ModIndex.writeCache(feed, index)
|
|
if type(feed) ~= "string" or feed == "" then return false end
|
|
local ok = pcall(function()
|
|
local SaveData = require("src.core.SaveData")
|
|
local opts = loadOptions()
|
|
opts.modIndexCache = opts.modIndexCache or {}
|
|
opts.modIndexCache[feed] = {
|
|
checkedAt = os.time(),
|
|
generatedAt = index.generatedAt,
|
|
categories = index.categories,
|
|
mods = index.mods,
|
|
}
|
|
SaveData.saveOptions(opts)
|
|
end)
|
|
return ok
|
|
end
|
|
|
|
-- ------- host I/O (HostShell's transport: curl, or the Android JNI bridge)
|
|
|
|
-- Plain GET returning the body. No GitHub Accept header: the feed and the
|
|
-- description markdown are static files on Pages, and the public feed is
|
|
-- explicitly unauthenticated. The transport itself lives in
|
|
-- src/core/HostShell.lua because Android has no curl and has to fetch through
|
|
-- love.system.httpDownload instead (#597).
|
|
function ModIndex.httpGet(url)
|
|
local HostShell = require("src.core.HostShell")
|
|
if not HostShell.canFetch() then
|
|
return nil, "no network transport on this platform"
|
|
end
|
|
return HostShell.httpGet(url, "gen1recomp-mod-index")
|
|
end
|
|
|
|
-- fetch(source, opts) -> index, err, meta
|
|
-- index is the parse() table; meta is { fromCache, stale }. opts.force skips
|
|
-- the 24h cache. A failed live fetch falls back to whatever is cached, marked
|
|
-- stale, so going offline degrades the listing rather than emptying it.
|
|
function ModIndex.fetch(source, opts)
|
|
opts = opts or {}
|
|
if type(source) ~= "table" or type(source.feed) ~= "string" then
|
|
return nil, "missing index source"
|
|
end
|
|
local feed = source.feed
|
|
|
|
local function cached(stale)
|
|
local entry = ModIndex.readCache(feed)
|
|
if not entry then return nil end
|
|
return {
|
|
schemaVersion = ModIndex.SCHEMA_VERSION,
|
|
generatedAt = entry.generatedAt,
|
|
categories = entry.categories or {},
|
|
mods = entry.mods or {},
|
|
}, nil, { fromCache = true, stale = stale, checkedAt = entry.checkedAt }
|
|
end
|
|
|
|
if not opts.force then
|
|
local entry = ModIndex.readCache(feed)
|
|
if ModIndex.cacheFresh(entry) then return cached(false) end
|
|
end
|
|
|
|
local body, err = ModIndex.httpGet(feed)
|
|
-- Pages deploys trail a push; the raw mirror is the same file, so a feed
|
|
-- that 404s right after a release is worth one retry elsewhere before it
|
|
-- counts as an outage.
|
|
if not body and source.fallback then
|
|
body = ModIndex.httpGet(source.fallback)
|
|
end
|
|
if not body then
|
|
local index, _, meta = cached(true)
|
|
if index then return index, nil, meta end
|
|
return nil, err
|
|
end
|
|
|
|
local index, parseErr = ModIndex.parse(body)
|
|
if not index then
|
|
local stale, _, meta = cached(true)
|
|
if stale then return stale, parseErr, meta end
|
|
return nil, parseErr
|
|
end
|
|
ModIndex.writeCache(feed, index)
|
|
return index, nil, { fromCache = false, checkedAt = os.time() }
|
|
end
|
|
|
|
-- Fetch a description_url / any index-relative text file. Returns the raw
|
|
-- markdown; callers run it through ModUpdate.cleanBody for display.
|
|
function ModIndex.fetchText(url)
|
|
if type(url) ~= "string" or url == "" then return nil, "no description" end
|
|
return ModIndex.httpGet(url)
|
|
end
|
|
|
|
-- Download a thumbnail into the save directory and return the love.filesystem
|
|
-- relative path. Reuses ModUpdate.downloadZip, which is a plain curl -o with
|
|
-- a non-empty-file check -- nothing in it is zip-specific.
|
|
function ModIndex.downloadThumbnail(url, modId)
|
|
if type(url) ~= "string" or url == "" then return nil, "no thumbnail" end
|
|
local ModUpdate = require("src.mods.ModUpdate")
|
|
local ext = url:match("%.(%a%a%a?%a?)$") or "png"
|
|
local name = ("mod_thumb_%s.%s"):format(tostring(modId):gsub("[^%w%-_]", "_"), ext)
|
|
return ModUpdate.downloadZip(url, name)
|
|
end
|
|
|
|
return ModIndex
|