123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583 |
- --Minetest
- --Copyright (C) 2018-24 rubenwardy
- --
- --This program is free software; you can redistribute it and/or modify
- --it under the terms of the GNU Lesser General Public License as published by
- --the Free Software Foundation; either version 2.1 of the License, or
- --(at your option) any later version.
- --
- --This program is distributed in the hope that it will be useful,
- --but WITHOUT ANY WARRANTY; without even the implied warranty of
- --MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- --GNU Lesser General Public License for more details.
- --
- --You should have received a copy of the GNU Lesser General Public License along
- --with this program; if not, write to the Free Software Foundation, Inc.,
- --51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
- if not core.get_http_api then
- return
- end
- contentdb = {
- loading = false,
- load_ok = false,
- load_error = false,
- -- Unordered preserves the original order of the ContentDB API,
- -- before the package list is ordered based on installed state.
- packages = {},
- packages_full = {},
- packages_full_unordered = {},
- package_by_id = {},
- aliases = {},
- number_downloading = 0,
- download_queue = {},
- REASON_NEW = "new",
- REASON_UPDATE = "update",
- REASON_DEPENDENCY = "dependency",
- }
- local function get_download_url(package, reason)
- local base_url = core.settings:get("contentdb_url")
- local ret = base_url .. ("/packages/%s/releases/%d/download/"):format(
- package.url_part, package.release)
- if reason then
- ret = ret .. "?reason=" .. reason
- end
- return ret
- end
- local function download_and_extract(param)
- local package = param.package
- local filename = core.get_temp_path(true)
- if filename == "" or not core.download_file(param.url, filename) then
- core.log("error", "Downloading " .. dump(param.url) .. " failed")
- return {
- msg = fgettext_ne("Failed to download \"$1\"", package.title)
- }
- end
- local tempfolder = core.get_temp_path()
- if tempfolder ~= "" and not core.extract_zip(filename, tempfolder) then
- tempfolder = ""
- end
- os.remove(filename)
- if tempfolder == "" then
- return {
- msg = fgettext_ne("Failed to extract \"$1\" " ..
- "(insufficient disk space, unsupported file type or broken archive)",
- package.title),
- }
- end
- return {
- path = tempfolder
- }
- end
- local function start_install(package, reason)
- local params = {
- package = package,
- url = get_download_url(package, reason),
- }
- contentdb.number_downloading = contentdb.number_downloading + 1
- local function callback(result)
- if result.msg then
- gamedata.errormessage = result.msg
- else
- local path, msg = pkgmgr.install_dir(package.type, result.path, package.name, package.path)
- core.delete_dir(result.path)
- if not path then
- gamedata.errormessage = fgettext_ne("Error installing \"$1\": $2", package.title, msg)
- else
- core.log("action", "Installed package to " .. path)
- local conf_path
- local name_is_title = false
- if package.type == "mod" then
- local actual_type = pkgmgr.get_folder_type(path)
- if actual_type.type == "modpack" then
- conf_path = path .. DIR_DELIM .. "modpack.conf"
- else
- conf_path = path .. DIR_DELIM .. "mod.conf"
- end
- elseif package.type == "game" then
- conf_path = path .. DIR_DELIM .. "game.conf"
- name_is_title = true
- elseif package.type == "txp" then
- conf_path = path .. DIR_DELIM .. "texture_pack.conf"
- end
- if conf_path then
- local conf = Settings(conf_path)
- if not conf:get("title") then
- conf:set("title", package.title)
- end
- if not name_is_title then
- conf:set("name", package.name)
- end
- if not conf:get("description") then
- conf:set("description", package.short_description)
- end
- conf:set("author", package.author)
- conf:set("release", package.release)
- conf:write()
- end
- pkgmgr.reload_by_type(package.type)
- end
- end
- package.downloading = false
- contentdb.number_downloading = contentdb.number_downloading - 1
- local next = contentdb.download_queue[1]
- if next then
- table.remove(contentdb.download_queue, 1)
- start_install(next.package, next.reason)
- end
- ui.update()
- end
- package.queued = false
- package.downloading = true
- if not core.handle_async(download_and_extract, params, callback) then
- core.log("error", "ERROR: async event failed")
- gamedata.errormessage = fgettext_ne("Failed to download $1", package.name)
- return
- end
- end
- function contentdb.queue_download(package, reason)
- if package.queued or package.downloading then
- return
- end
- local max_concurrent_downloads = tonumber(core.settings:get("contentdb_max_concurrent_downloads"))
- if contentdb.number_downloading < math.max(max_concurrent_downloads, 1) then
- start_install(package, reason)
- else
- table.insert(contentdb.download_queue, { package = package, reason = reason })
- package.queued = true
- end
- end
- function contentdb.get_package_by_id(id)
- return contentdb.package_by_id[id]
- end
- -- Create a coroutine from `fn` and provide results to `callback` when complete (dead).
- -- Returns a resumer function.
- local function make_callback_coroutine(fn, callback)
- local co = coroutine.create(fn)
- local function resumer(...)
- local ok, result = coroutine.resume(co, ...)
- if not ok then
- error(result)
- elseif coroutine.status(co) == "dead" then
- callback(result)
- end
- end
- return resumer
- end
- local function get_raw_dependencies_async(package)
- local url_fmt = "/api/packages/%s/dependencies/?only_hard=1&protocol_version=%s&engine_version=%s"
- local version = core.get_version()
- local base_url = core.settings:get("contentdb_url")
- local url = base_url .. url_fmt:format(package.url_part, core.get_max_supp_proto(), core.urlencode(version.string))
- local http = core.get_http_api()
- local response = http.fetch_sync({ url = url })
- if not response.succeeded then
- return nil
- end
- return core.parse_json(response.data) or {}
- end
- local function get_raw_dependencies_co(package, resumer)
- if package.type ~= "mod" then
- return {}
- end
- if package.raw_deps then
- return package.raw_deps
- end
- core.handle_async(get_raw_dependencies_async, package, resumer)
- local data = coroutine.yield()
- if not data then
- return nil
- end
- for id, raw_deps in pairs(data) do
- local package2 = contentdb.package_by_id[id:lower()]
- if package2 and not package2.raw_deps then
- package2.raw_deps = raw_deps
- for _, dep in pairs(raw_deps) do
- local packages = {}
- for i=1, #dep.packages do
- packages[#packages + 1] = contentdb.package_by_id[dep.packages[i]:lower()]
- end
- dep.packages = packages
- end
- end
- end
- return package.raw_deps
- end
- local function has_hard_deps_co(package, resumer)
- local raw_deps = get_raw_dependencies_co(package, resumer)
- if not raw_deps then
- return nil
- end
- for i=1, #raw_deps do
- if not raw_deps[i].is_optional then
- return true
- end
- end
- return false
- end
- function contentdb.has_hard_deps(package, callback)
- local resumer = make_callback_coroutine(has_hard_deps_co, callback)
- resumer(package, resumer)
- end
- -- Recursively resolve dependencies, given the installed mods
- local function resolve_dependencies_2_co(raw_deps, installed_mods, out, resumer)
- local function resolve_dep(dep)
- -- Check whether it's already installed
- if installed_mods[dep.name] then
- return {
- is_optional = dep.is_optional,
- name = dep.name,
- installed = true,
- }
- end
- -- Find exact name matches
- local fallback
- for _, package in pairs(dep.packages) do
- if package.type ~= "game" then
- if package.name == dep.name then
- return {
- is_optional = dep.is_optional,
- name = dep.name,
- installed = false,
- package = package,
- }
- elseif not fallback then
- fallback = package
- end
- end
- end
- -- Otherwise, find the first mod that fulfills it
- if fallback then
- return {
- is_optional = dep.is_optional,
- name = dep.name,
- installed = false,
- package = fallback,
- }
- end
- return {
- is_optional = dep.is_optional,
- name = dep.name,
- installed = false,
- }
- end
- for _, dep in pairs(raw_deps) do
- if not dep.is_optional and not out[dep.name] then
- local result = resolve_dep(dep)
- out[dep.name] = result
- if result and result.package and not result.installed then
- local raw_deps2 = get_raw_dependencies_co(result.package, resumer)
- if raw_deps2 then
- resolve_dependencies_2_co(raw_deps2, installed_mods, out, resumer)
- end
- end
- end
- end
- return true
- end
- local function resolve_dependencies_co(package, game, resumer)
- assert(game)
- local raw_deps = get_raw_dependencies_co(package, resumer)
- local installed_mods = {}
- local mods = {}
- pkgmgr.get_game_mods(game, mods)
- for _, mod in pairs(mods) do
- installed_mods[mod.name] = true
- end
- for _, mod in pairs(pkgmgr.global_mods:get_list()) do
- installed_mods[mod.name] = true
- end
- local out = {}
- if not resolve_dependencies_2_co(raw_deps, installed_mods, out, resumer) then
- return nil
- end
- local retval = {}
- for _, dep in pairs(out) do
- retval[#retval + 1] = dep
- end
- table.sort(retval, function(a, b)
- return a.name < b.name
- end)
- return retval
- end
- -- Resolve dependencies for a package, calls the recursive version.
- function contentdb.resolve_dependencies(package, game, callback)
- local resumer = make_callback_coroutine(resolve_dependencies_co, callback)
- resumer(package, game, resumer)
- end
- local function fetch_pkgs(params)
- local version = core.get_version()
- local base_url = core.settings:get("contentdb_url")
- local url = base_url ..
- "/api/packages/?type=mod&type=game&type=txp&protocol_version=" ..
- core.get_max_supp_proto() .. "&engine_version=" .. core.urlencode(version.string)
- for _, item in pairs(core.settings:get("contentdb_flag_blacklist"):split(",")) do
- item = item:trim()
- if item ~= "" then
- url = url .. "&hide=" .. core.urlencode(item)
- end
- end
- local languages
- local current_language = core.get_language()
- if current_language ~= "" then
- languages = { current_language, "en;q=0.8" }
- else
- languages = { "en" }
- end
- local http = core.get_http_api()
- local response = http.fetch_sync({
- url = url,
- extra_headers = {
- "Accept-Language: " .. table.concat(languages, ", ")
- },
- })
- if not response.succeeded then
- return
- end
- local packages = core.parse_json(response.data)
- if not packages or #packages == 0 then
- return
- end
- local aliases = {}
- for _, package in pairs(packages) do
- local name_len = #package.name
- -- This must match what contentdb.update_paths() does!
- package.id = package.author:lower() .. "/"
- if package.type == "game" and name_len > 5 and package.name:sub(name_len - 4) == "_game" then
- package.id = package.id .. package.name:sub(1, name_len - 5)
- else
- package.id = package.id .. package.name
- end
- package.url_part = core.urlencode(package.author) .. "/" .. core.urlencode(package.name)
- if package.aliases then
- for _, alias in ipairs(package.aliases) do
- -- We currently don't support name changing
- local suffix = "/" .. package.name
- if alias:sub(-#suffix) == suffix then
- aliases[alias:lower()] = package.id
- end
- end
- end
- end
- return { packages = packages, aliases = aliases }
- end
- function contentdb.fetch_pkgs(callback)
- contentdb.loading = true
- core.handle_async(fetch_pkgs, nil, function(result)
- if result then
- contentdb.load_ok = true
- contentdb.load_error = false
- contentdb.packages = result.packages
- contentdb.packages_full = result.packages
- contentdb.packages_full_unordered = result.packages
- contentdb.aliases = result.aliases
- for _, package in ipairs(result.packages) do
- contentdb.package_by_id[package.id] = package
- end
- else
- contentdb.load_error = true
- end
- contentdb.loading = false
- callback(result)
- end)
- end
- function contentdb.update_paths()
- pkgmgr.load_all()
- local mod_hash = {}
- for _, mod in pairs(pkgmgr.global_mods:get_list()) do
- local cdb_id = pkgmgr.get_contentdb_id(mod)
- if cdb_id then
- mod_hash[contentdb.aliases[cdb_id] or cdb_id] = mod
- end
- end
- local game_hash = {}
- for _, game in pairs(pkgmgr.games) do
- local cdb_id = pkgmgr.get_contentdb_id(game)
- if cdb_id then
- game_hash[contentdb.aliases[cdb_id] or cdb_id] = game
- end
- end
- local txp_hash = {}
- for _, txp in pairs(pkgmgr.texture_packs) do
- local cdb_id = pkgmgr.get_contentdb_id(txp)
- if cdb_id then
- txp_hash[contentdb.aliases[cdb_id] or cdb_id] = txp
- end
- end
- for _, package in pairs(contentdb.packages_full) do
- local content
- if package.type == "mod" then
- content = mod_hash[package.id]
- elseif package.type == "game" then
- content = game_hash[package.id]
- elseif package.type == "txp" then
- content = txp_hash[package.id]
- end
- if content then
- package.path = content.path
- package.installed_release = content.release or 0
- else
- package.path = nil
- package.installed_release = nil
- end
- end
- end
- function contentdb.sort_packages()
- local ret = {}
- -- Add installed content
- for _, pkg in ipairs(contentdb.packages_full_unordered) do
- if pkg.path then
- ret[#ret + 1] = pkg
- end
- end
- -- Sort installed content first by "is there an update available?", then by title
- table.sort(ret, function(a, b)
- local a_updatable = a.installed_release < a.release
- local b_updatable = b.installed_release < b.release
- if a_updatable and not b_updatable then
- return true
- elseif b_updatable and not a_updatable then
- return false
- end
- return a.title < b.title
- end)
- -- Add uninstalled content
- for _, pkg in ipairs(contentdb.packages_full_unordered) do
- if not pkg.path then
- ret[#ret + 1] = pkg
- end
- end
- contentdb.packages_full = ret
- end
- function contentdb.filter_packages(query, by_type)
- if query == "" and by_type == nil then
- contentdb.packages = contentdb.packages_full
- return
- end
- local keywords = {}
- for word in query:lower():gmatch("%S+") do
- table.insert(keywords, word)
- end
- local function matches_keywords(package)
- for k = 1, #keywords do
- local keyword = keywords[k]
- if string.find(package.name:lower(), keyword, 1, true) or
- string.find(package.title:lower(), keyword, 1, true) or
- string.find(package.author:lower(), keyword, 1, true) or
- string.find(package.short_description:lower(), keyword, 1, true) then
- return true
- end
- end
- return false
- end
- contentdb.packages = {}
- for _, package in pairs(contentdb.packages_full) do
- if (query == "" or matches_keywords(package)) and
- (by_type == nil or package.type == by_type) then
- contentdb.packages[#contentdb.packages + 1] = package
- end
- end
- end
|