diff --git a/packages/controller/src/ControlConnection.ts b/packages/controller/src/ControlConnection.ts index 8ef23516..315e6b92 100644 --- a/packages/controller/src/ControlConnection.ts +++ b/packages/controller/src/ControlConnection.ts @@ -98,6 +98,7 @@ export default class ControlConnection extends BaseConnection { this.handle(lib.ModGetRequest, this.handleModGetRequest.bind(this)); this.handle(lib.ModListRequest, this.handleModListRequest.bind(this)); this.handle(lib.ModSearchRequest, this.handleModSearchRequest.bind(this)); + this.handle(lib.ModPortalSearchRequest, this.handleModPortalSearchRequest.bind(this)); this.handle(lib.ModDeleteRequest, this.handleModDeleteRequest.bind(this)); this.handle(lib.LogSetSubscriptionsRequest, this.handleLogSetSubscriptionsRequest.bind(this)); this.handle(lib.LogQueryRequest, this.handleLogQueryRequest.bind(this)); @@ -514,6 +515,22 @@ export default class ControlConnection extends BaseConnection { }; } + async handleModPortalSearchRequest(request: lib.ModPortalSearchRequest) { + try { + return lib.ModStore.searchModPortal( + request.query, + request.factorioVersion, + request.page, + request.pageSize || 10, + request.sort, + request.sortOrder || "asc" + ); + } catch (error: any) { + logger.error(`Error searching mod portal: ${error}`); + throw new lib.RequestError(`Failed to search mod portal: ${error.message}`); + } + } + async handleModDeleteRequest(request: lib.ModDeleteRequest) { await this._controller.modStore.deleteMod(request.name, request.version); } diff --git a/packages/lib/src/ModStore.ts b/packages/lib/src/ModStore.ts index 0f85cfe3..0c3b8fb6 100644 --- a/packages/lib/src/ModStore.ts +++ b/packages/lib/src/ModStore.ts @@ -1,6 +1,6 @@ import fs from "fs-extra"; import path from "path"; -import { Static} from "@sinclair/typebox"; +import { Static } from "@sinclair/typebox"; import { logger } from "./logging"; import { integerModVersion, ModInfo, ModPack, ModRecord } from "./data"; import TypedEventEmitter from "./TypedEventEmitter"; @@ -36,7 +36,8 @@ interface ModDetails { downloads_count: number, category: string, score?: number, - releases: Array, + releases?: Array, + latest_release?: ModRelease, } interface ModsInfoResponse { @@ -196,9 +197,14 @@ export default class ModStore extends TypedEventEmitter(); mods.forEach((mod) => { const modName = mod.name; - let latestVersion = ModStore.getLatestVersionFromReleases(mod.releases); - if (latestVersion !== undefined) { versions.set(modName, latestVersion); } - + if (mod.releases) { + let latestVersion = ModStore.getLatestVersionFromReleases(mod.releases); + if (latestVersion !== undefined) { versions.set(modName, latestVersion); } + } else if (mod.latest_release) { + versions.set(modName, mod.latest_release.version); + } else { + logger.warn(`Mod ${modName} has no releases or latest_release`); + } }); return versions; } @@ -217,8 +223,9 @@ export default class ModStore extends TypedEventEmitter= 3) { factorioVersion = factorioVersion.slice(0, factorioVersion.lastIndexOf(".")); } - modNames = modNames.filter((modName, index, array) => !(modName in ModPack.getBuiltinModNames(factorioVersion)) - ); + + const builtinMods = ModPack.getBuiltinModNames(factorioVersion); + modNames = modNames.filter(modName => !(modName in builtinMods)); const chunkSize = 500; let versions = new Map(); @@ -269,8 +276,16 @@ export default class ModStore extends TypedEventEmitter this.downloadModFromReleases(mod.releases, - modVersions[modNames.indexOf(mod.name)], username, token))); + await Promise.all(mods.map((mod) => { + if (mod.releases) { + return this.downloadModFromReleases(mod.releases, + modVersions[modNames.indexOf(mod.name)], username, token); + } else if (mod.latest_release) { + return this.downloadModFromReleases([mod.latest_release], + modVersions[modNames.indexOf(mod.name)], username, token); + } + throw new Error(`Mod ${mod.name} has no releases or latest_release`); + })); } @@ -418,4 +433,96 @@ export default class ModStore extends TypedEventEmitter { + // Find the latest release for the specified Factorio version + const latestRelease = mod.latest_release; + + const transformedMod = { + name: mod.name, + title: mod.title, + summary: mod.summary, + owner: mod.owner, + downloads_count: mod.downloads_count, + latest_release: latestRelease ? { + version: latestRelease.version, + factorio_version: latestRelease.info_json.factorio_version, + released_at: latestRelease.released_at, + download_url: latestRelease.download_url, + file_name: latestRelease.file_name, + sha1: latestRelease.sha1, + } : undefined, + }; + return transformedMod; + }).filter(mod => mod.latest_release); // Only include mods with a release for the specified version + + return { + queryIssues: [], + pageCount: Math.ceil(data.pagination.count / pageSize), + resultCount: data.pagination.count, + results, + }; + } } diff --git a/packages/lib/src/data/messages_mod.ts b/packages/lib/src/data/messages_mod.ts index 60fc6563..3dd61955 100644 --- a/packages/lib/src/data/messages_mod.ts +++ b/packages/lib/src/data/messages_mod.ts @@ -197,6 +197,91 @@ export class ModSearchRequest { }; } +export class ModPortalSearchRequest { + declare ["constructor"]: typeof ModPortalSearchRequest; + static type = "request" as const; + static src = "control" as const; + static dst = "controller" as const; + static permission = "core.mod.search_portal" as const; + + constructor( + public query: string, + public factorioVersion: string, + public page: number, + public pageSize?: number, + public sort?: string, + public sortOrder?: string, + ) { } + + static jsonSchema = Type.Object({ + "query": Type.String(), + "factorioVersion": Type.String(), + "page": Type.Integer(), + "pageSize": Type.Optional(Type.Integer()), + "sort": Type.Optional(Type.String()), + "sortOrder": Type.Optional(Type.String()), + }); + + static fromJSON(json: Static) { + return new this(json.query, json.factorioVersion, json.page, json.pageSize, json.sort, json.sortOrder); + } + + static Response = class Response { + constructor( + public queryIssues: string[], + public pageCount: number, + public resultCount: number, + public results: { + name: string, + title: string, + summary: string, + owner: string, + downloads_count: number, + latest_release: { + version: string, + factorio_version: string, + released_at: string, + download_url: string, + file_name: string, + sha1: string, + }, + }[], + ) { } + + static jsonSchema = Type.Object({ + "queryIssues": Type.Array(Type.String()), + "pageCount": Type.Integer(), + "resultCount": Type.Integer(), + "results": Type.Array( + Type.Object({ + "name": Type.String(), + "title": Type.String(), + "summary": Type.String(), + "owner": Type.String(), + "downloads_count": Type.Number(), + "latest_release": Type.Object({ + "version": Type.String(), + "factorio_version": Type.String(), + "released_at": Type.String(), + "download_url": Type.String(), + "file_name": Type.String(), + "sha1": Type.String(), + }), + }), + ), + }); + + static fromJSON(json: Static) { + return new this( + json.queryIssues, + json.pageCount, + json.resultCount, + json.results + ); + } + }; +} + export class ModDownloadRequest { declare ["constructor"]: typeof ModDownloadRequest; static type = "request" as const; diff --git a/packages/lib/src/link/messages.ts b/packages/lib/src/link/messages.ts index 4182db3a..b1c17826 100644 --- a/packages/lib/src/link/messages.ts +++ b/packages/lib/src/link/messages.ts @@ -97,6 +97,7 @@ export const dataClasses: (RequestClass | EventClass) mod.ModGetRequest, mod.ModListRequest, mod.ModSearchRequest, + mod.ModPortalSearchRequest, mod.ModDownloadRequest, mod.ModDeleteRequest, mod.ModPackUpdatesEvent, diff --git a/packages/lib/src/permissions.ts b/packages/lib/src/permissions.ts index 306047de..4e64684d 100644 --- a/packages/lib/src/permissions.ts +++ b/packages/lib/src/permissions.ts @@ -373,6 +373,12 @@ definePermission({ description: "Search through the list of mods stored on the controller.", grantByDefault: true, }); +definePermission({ + name: "core.mod.search_portal", + title: "Search mod portal", + description: "Search for mods on the Factorio mod portal.", + grantByDefault: true, +}); definePermission({ name: "core.mod.subscribe", title: "Subscribe to mod updates", diff --git a/packages/web_ui/src/components/ModsPage.tsx b/packages/web_ui/src/components/ModsPage.tsx index eeec0224..767b32bf 100644 --- a/packages/web_ui/src/components/ModsPage.tsx +++ b/packages/web_ui/src/components/ModsPage.tsx @@ -1,8 +1,9 @@ -import React, { Fragment, useState, useContext } from "react"; +import React, { Fragment, useState, useContext, useEffect } from "react"; import { useNavigate } from "react-router-dom"; import { Button, Form, Input, Modal, Popconfirm, Space, Table, Typography, Upload } from "antd"; import ImportOutlined from "@ant-design/icons/ImportOutlined"; import PlusOutlined from "@ant-design/icons/PlusOutlined"; +import SearchOutlined from "@ant-design/icons/SearchOutlined"; import * as lib from "@clusterio/lib"; @@ -129,6 +130,184 @@ function CreateModPackButton() { ; } +function SearchModsButton() { + const control = useContext(ControlContext); + const account = useAccount(); + const [open, setOpen] = useState(false); + const [form] = Form.useForm(); + const [searchText, setSearchText] = useState(""); + const [factorioVersion, setFactorioVersion] = useState("1.1"); + const [modResults, setModResults] = useState([]); + const [loading, setLoading] = useState(false); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(10); + const [totalResults, setTotalResults] = useState(0); + const [totalPages, setTotalPages] = useState(0); + const [sort, setSort] = useState("name"); + const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc"); + + // Search mods when search parameters change + useEffect(() => { + if (!open) { return; } + + setLoading(true); + let canceled = false; + + control.send(new lib.ModPortalSearchRequest( + searchText, + factorioVersion, + page, + pageSize, + sort, + sortOrder + )).then(response => { + if (canceled) { return; } + + setModResults(response.results); + setTotalResults(response.resultCount); + setTotalPages(response.pageCount); + setLoading(false); + }).catch(error => { + if (canceled) { return; } + setLoading(false); + }); + + return () => { + canceled = true; + }; + }, [open, searchText, factorioVersion, page, pageSize, sort, sortOrder, control]); + + const handleSearch = () => { + const values = form.getFieldsValue(); + setSearchText(values.name || ""); + setFactorioVersion(values.factorioVersion || "1.1"); + setPage(1); // Reset to first page on new search + }; + + const handleTableChange = ( + pagination: any, + filters: any, + sorter: any + ) => { + if (pagination.current !== page) { + setPage(pagination.current); + } + if (pagination.pageSize !== pageSize) { + setPageSize(pagination.pageSize); + } + + if (sorter.field && sorter.order) { + setSort(sorter.field); + setSortOrder(sorter.order === "ascend" ? "asc" : "desc"); + } + }; + + return <> + + { setOpen(false); }} + width={800} + footer={[ + , + , + ]} + > +
+ + + + + + +
+ + record.name} + loading={loading} + onChange={handleTableChange} + pagination={{ + current: page, + pageSize: pageSize, + total: totalResults, + showSizeChanger: true, + pageSizeOptions: ["10", "20", "50"], + }} + expandable={{ + expandedRowRender: record => ( +
+

Summary: {record.summary}

+

Downloads: {record.downloads_count}

+

Latest Release:

+
    +
  • Version: {record.latest_release.version}
  • +
  • Factorio Version: {record.latest_release.info_json.factorio_version}
  • +
  • Released: {new Date(record.latest_release.released_at).toLocaleString()}
  • +
+ {account.hasPermission("core.mod.download") && ( + + )} +
+ ), + }} + columns={[ + { + title: "Name", + dataIndex: "name", + key: "name", + sorter: true, + sortOrder: sort === "name" ? (sortOrder === "asc" ? "ascend" : "descend") : undefined, + }, + { + title: "Title", + dataIndex: "title", + key: "title", + sorter: true, + sortOrder: sort === "title" ? (sortOrder === "asc" ? "ascend" : "descend") : undefined, + }, + { + title: "Author", + dataIndex: "owner", + key: "owner", + sorter: true, + sortOrder: sort === "owner" ? (sortOrder === "asc" ? "ascend" : "descend") : undefined, + }, + { + title: "Latest Version", + key: "version", + render: (_, record) => record.latest_release.version, + }, + ]} + /> + + ; +} + export default function ModsPage() { let account = useAccount(); let control = useContext(ControlContext); @@ -223,7 +402,10 @@ export default function ModsPage() { }, })} /> - + + + {uploadButton} + } />