From 3eb63db3130bb0473832063f7ec6fc7d2aef5abf Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Sat, 24 Sep 2022 21:10:38 +0200 Subject: [PATCH] Add search mods request Add paged search mods request suitable for showing a real time list of results for a user provided search expression. --- packages/ctl/src/commands.js | 61 +++++++++++++++- packages/lib/link/messages.js | 36 ++++++++++ packages/lib/users.js | 8 ++- packages/master/src/ControlConnection.js | 90 ++++++++++++++++++++++++ test/integration/clusterio.js | 7 ++ 5 files changed, 200 insertions(+), 2 deletions(-) diff --git a/packages/ctl/src/commands.js b/packages/ctl/src/commands.js index 9dc00bcb..81400b8d 100644 --- a/packages/ctl/src/commands.js +++ b/packages/ctl/src/commands.js @@ -1087,7 +1087,7 @@ modCommands.add(new libCommand.Command({ definition: [["list", "l"], "List mods stored in the cluster", (yargs) => { yargs.options({ "fields": { - describe: "Fields", + describe: "Fields to show, supports 'all'.", array: true, type: "string", default: ["name", "version", "title", "factorio_version"], @@ -1109,6 +1109,65 @@ modCommands.add(new libCommand.Command({ }, })); +modCommands.add(new libCommand.Command({ + definition: ["search [query]", "Search mods stored in the cluster", (yargs) => { + yargs.positional("factorio-version", { describe: "Major version of Factorio to search for", type: "string" }); + yargs.positional("query", { describe: "Search query", type: "string", default: "" }); + yargs.options({ + "page": { + describe: "Result page to show", + type: "number", + default: 1, + }, + "page-size": { + describe: "Results per page to show", + type: "number", + default: 10, + }, + "sort": { + describe: "sort results by given field", + type: "string", + }, + "sort-order": { + describe: "order to sort results in (asc/desc)", + type: "string", + default: "asc", + }, + "fields": { + describe: "Fields to show, supports 'all'.", + array: true, + type: "string", + default: ["name", "version", "title", "factorio_version"], + }, + }); + }], + handler: async function(args, control) { + let response = await libLink.messages.searchMods.send(control, { + "query": args.query, + "factorio_version": args.factorioVersion, + "page_size": args.pageSize, + "page": args.page, + "sort": args.sort, + "sort_order": args.sortOrder, + }); + let results = response.results.flatMap(result => result.versions); + if (!args.fields.includes("all")) { + for (let entry of results) { + for (let field of Object.keys(entry)) { + if (!args.fields.includes(field)) { + delete entry[field]; + } + } + } + } + for (let issue of response.query_issues) { + print(issue); + } + print(`page ${args.page} of ${response.page_count} (${response.result_count} results)`); + print(asTable(results)); + }, +})); + modCommands.add(new libCommand.Command({ definition: ["upload ", "Upload mod to the cluster", (yargs) => { yargs.positional("file", { describe: "File to upload", type: "string" }); diff --git a/packages/lib/link/messages.js b/packages/lib/link/messages.js index 61b00ce1..4404a25d 100644 --- a/packages/lib/link/messages.js +++ b/packages/lib/link/messages.js @@ -800,6 +800,42 @@ messages.listMods = new Request({ }, }); +messages.searchMods = new Request({ + type: "search_mods", + links: ["control-master"], + permission: "core.mod.search", + requestRequired: ["query", "factorio_version", "page"], + requestProperties: { + "query": { type: "string" }, + "factorio_version": { type: "string" }, + "page_size": { type: "integer" }, + "page": { type: "integer" }, + "sort": { type: "string" }, + "sort_order": { type: "string" }, + }, + responseProperties: { + "query_issues": { + type: "array", + items: { type: "string" }, + }, + "page_count": { type: "integer" }, + "result_count": { type: "integer" }, + "results": { + type: "array", + items: { + type: "object", + properties: { + "name": { type: "string" }, + "versions": { + type: "array", + items: libData.ModInfo.jsonSchema, + }, + }, + }, + }, + }, +}); + messages.setModSubscriptions = new Request({ type: "set_mod_subscriptions", links: ["control-master"], diff --git a/packages/lib/users.js b/packages/lib/users.js index f6ef8558..4f3fa0e8 100644 --- a/packages/lib/users.js +++ b/packages/lib/users.js @@ -553,7 +553,13 @@ definePermission({ definePermission({ name: "core.mod.list", title: "List mods", - description: "Get the full list of mods stored on the master", + description: "Get the full list of mods stored on the master.", + grantByDefault: true, +}); +definePermission({ + name: "core.mod.search", + title: "Search mods", + description: "Search through the list of mods stored on the master.", grantByDefault: true, }); definePermission({ diff --git a/packages/master/src/ControlConnection.js b/packages/master/src/ControlConnection.js index 12472705..bcae4226 100644 --- a/packages/master/src/ControlConnection.js +++ b/packages/master/src/ControlConnection.js @@ -18,6 +18,8 @@ const libUsers = require("@clusterio/lib/users"); const BaseConnection = require("./BaseConnection"); const routes = require("./routes"); +const strcmp = new Intl.Collator(undefined, { numerice: "true", sensitivity: "base" }).compare; + const lastQueryLogTime = new libPrometheus.Gauge( "clusterio_master_last_query_log_duration_seconds", "Time in seconds the last log query took to execute." @@ -543,6 +545,94 @@ class ControlConnection extends BaseConnection { return { list: [...this._master.mods.values()].map(mod => mod.toJSON()) }; } + static termsMatchesMod(terms, mod) { + for (let term of terms) { + if (term.type === "word") { + if (!libHelpers.wordMatches(term, + mod.name, mod.version, mod.title, mod.author, mod.contact, + mod.homepage, mod.description, mod.filename + )) { + return false; + } + } else if (term.type === "attribute") { + if (!libHelpers.wordMatches(term.value, mod[term.name])) { + return false; + } + } + } + return true; + } + + async searchModsRequestHandler(message) { + let query = libHelpers.parseSearchString(message.data.query, { + name: "word", + // version + title: "word", + author: "word", + contact: "word", + homepage: "word", + description: "word", + // factorioVersion + // dependencies + filename: "word", + // size + hash: "word", + }); + let factorioVersion = message.data.factorio_version; + + let results = new Map(); + for (let mod of this._master.mods.values()) { + if ( + mod.factorioVersion !== factorioVersion + || !ControlConnection.termsMatchesMod(query.terms, mod) + ) { + continue; + } + let result = results.get(mod.name); + if (!result) { + result = { + name: mod.name, + versions: [], + }; + results.set(mod.name, result); + } + result.versions.push(mod); + } + for (let result of results.values()) { + result.versions.sort((a, b) => b.integerVersion - a.integerVersion); + result.versions.map(e => e.toJSON()); + } + let resultList = [...results.values()]; + + const sort = message.data.sort; + if (sort) { + const sorters = { + name: (a, b) => strcmp(a.versions[0].name, b.versions[0].name), + title: (a, b) => strcmp(a.versions[0].title, b.versions[0].title), + author: (a, b) => strcmp(a.versions[0].author, b.versions[0].author), + }; + if (!Object.prototype.hasOwnProperty.call(sorters, sort)) { + throw new libErrors.RequestError(`Invalid value for sort: ${sort}`); + } + resultList.sort(sorters[sort]); + let order = message.data.sort_order; + if (order === "desc") { + resultList.reverse(); + } + } + + const page = message.data.page; + const pageSize = message.data.page_size || 10; + resultList = resultList.slice((page - 1) * pageSize, page * pageSize); + + return { + query_issues: query.issues, + page_count: Math.ceil(results.size / pageSize), + result_count: results.size, + results: resultList, + }; + } + async setModSubscriptionsRequestHandler(message) { this.modSubscriptions = message.data; } diff --git a/test/integration/clusterio.js b/test/integration/clusterio.js index 2cd230c8..8c88c244 100644 --- a/test/integration/clusterio.js +++ b/test/integration/clusterio.js @@ -929,6 +929,13 @@ describe("Integration of Clusterio", function() { }); }); + describe("mod search", function() { + it("searches the list of mods", async function() { + let result = await execCtl("mod search 1.1 name:empty_mod"); + assert(result.stdout.indexOf("empty_mod") !== -1, "empty_mod is not in the result"); + }); + }); + describe("mod download", function() { it("downloads a mod", async function() { await fs.unlink(path.join("temp", "test", "empty_mod_1.0.0.zip"));