diff --git a/docs/migrating-from-2.0.0-alpha.13.md b/docs/migrating-from-2.0.0-alpha.13.md index adddebdd..355db4b8 100644 --- a/docs/migrating-from-2.0.0-alpha.13.md +++ b/docs/migrating-from-2.0.0-alpha.13.md @@ -10,6 +10,10 @@ You can do this by running the follow RCON command on an instance that is curren /sc local m={name="pack",description="",factorio_version=game.active_mods.base,mods={},settings={startup={},["runtime-global"]={},["runtime-per-user"]={}}} for n,v in pairs(game.active_mods) do table.insert(m.mods,{name=n,enabled=true,version=v}) end local function set(s,c) for n,v in pairs(s) do m.settings[c][n]=v end end set(settings.startup,"startup") set(settings.global,"runtime-global") if game.player then set(game.player.mod_settings,"runtime-per-user") else set(settings.player,"runtime-per-user") end local e=game.encode_string(game.table_to_json(m)) log(e) rcon.print(e) +If you for some reason want to run this function in 2.0+, use the following command: + + /sc local a={name="pack",description="",factorio_version=script.active_mods.base,mods={},settings={startup={},["runtime-global"]={},["runtime-per-user"]={}}}for b,c in pairs(script.active_mods)do table.insert(a.mods,{name=b,enabled=true,version=c})end;local function d(e,f)for b,c in pairs(e)do a.settings[f][b]=c end end;d(settings.startup,"startup")d(settings.global,"runtime-global")if game.player then d(game.player.mod_settings,"runtime-per-user")else d(settings.player,"runtime-per-user")end;local g=helpers.encode_string(helpers.table_to_json(a))log(g)rcon.print(g) + This should produce a [Mod Pack String](https://forums.factorio.com/viewtopic.php?f=96&t=103578) which you need to copy and paste somewhere to keep it for later. Mod Pack strings looks something like this: diff --git a/packages/controller/src/ControlConnection.ts b/packages/controller/src/ControlConnection.ts index 5f3fac4b..92db23a5 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.ModPortalGetAllRequest, this.handleModPortalGetAllRequest.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)); @@ -119,6 +120,7 @@ export default class ControlConnection extends BaseConnection { this.handle(lib.UserBulkImportRequest, this.handleUserBulkImportRequest.bind(this)); this.handle(lib.UserBulkExportRequest, this.handleUserBulkExportRequest.bind(this)); this.handle(lib.DebugDumpWsRequest, this.handleDebugDumpWsRequest.bind(this)); + this.handle(lib.ModPortalDownloadRequest, this.handleModPortalDownloadRequest.bind(this)); } validateIngress(message: lib.MessageRequest | lib.MessageEvent) { @@ -524,6 +526,88 @@ export default class ControlConnection extends BaseConnection { }; } + /** + * Handle request to fetch all mods from the Factorio Mod Portal for a given version. + * @param request - The request object containing factorioVersion. + */ + async handleModPortalGetAllRequest(request: lib.ModPortalGetAllRequest) { + const cacheKey = `${request.factorioVersion}-${request.hide_deprecated}`; + const cachedData = this._controller.modPortalCache.get(cacheKey); + + const cacheDuration = this._controller.config.get("controller.mod_portal_cache_duration_minutes") * 60 * 1000; + if (cachedData && Date.now() - cachedData.timestamp < cacheDuration) { + return new lib.ModPortalGetAllRequest.Response(cachedData.data); + } + + try { + logger.info(`Fetching mod portal data for ${cacheKey}`); + const mods = await lib.ModStore.fetchAllModsFromPortal( + request.factorioVersion, + this._controller.config.get("controller.mod_portal_page_size"), + request.hide_deprecated + ); + this._controller.modPortalCache.set(cacheKey, { timestamp: Date.now(), data: mods }); + // The Response class is defined inline within the Request class + return new lib.ModPortalGetAllRequest.Response(mods); + } catch (error: any) { + logger.error(`Error fetching all mods from portal (${request.factorioVersion}): ${error}`); + // Propagate a user-friendly error back to the client + throw new lib.RequestError(`Portal mod fetch failed: ${error}`); + } + } + + /** + * Handle request to download a mod from the Factorio Mod Portal to the controller. + * @param request - Request object with mod name, version, and factorioVersion. + */ + async handleModPortalDownloadRequest(request: lib.ModPortalDownloadRequest): Promise { + const { name, version, factorioVersion } = request; + const filename = lib.ModInfo.filename(name, version); + + // Check if mod already exists + if (this._controller.modStore.files.has(filename)) { + logger.verbose(`Mod ${filename} already exists, skipping download.`); + return this._controller.modStore.files.get(filename)!; + } + + // Get Factorio credentials from config + const username = this._controller.config.get("controller.factorio_username"); + const token = this._controller.config.get("controller.factorio_token"); + + if (!username || !token) { + throw new lib.RequestError("Factorio credentials (username, token) not configured on the controller."); + } + + try { + logger.info(`Downloading mod ${filename} for Factorio ${factorioVersion} from portal.`); + const modMap = new Map([[name, version]]); + await this._controller.modStore.downloadMods(modMap, username || "", token || "", factorioVersion); + + // Verify download and return ModInfo + const downloadedMod = this._controller.modStore.files.get(filename); + if (!downloadedMod) { + throw new lib.RequestError(`Failed to find mod ${filename} in store after download attempt.`); + } + logger.info(`Successfully downloaded mod ${filename}.`); + return downloadedMod; + + } catch (error: any) { + logger.error(`Error downloading mod ${filename} from portal: ${error.message}`); + // Improve error message clarity + let errorMessage = `Mod portal download failed for ${name} v${version}`; + if (error instanceof lib.RequestError) { + errorMessage += `: ${error.message}`; + } else if (error.message.includes("401") || error.message.includes("Unauthorized")) { + errorMessage += ": Authentication failed. Check Factorio username/token in controller config."; + } else if (error.message.includes("404") || error.message.includes("Not Found")) { + errorMessage += ": Mod version not found on the portal."; + } else { + errorMessage += `: ${error.message}`; + } + throw new lib.RequestError(errorMessage); + } + } + async handleModDeleteRequest(request: lib.ModDeleteRequest) { await this._controller.modStore.deleteMod(request.name, request.version); } diff --git a/packages/controller/src/Controller.ts b/packages/controller/src/Controller.ts index 1517677a..18a38751 100644 --- a/packages/controller/src/Controller.ts +++ b/packages/controller/src/Controller.ts @@ -96,6 +96,9 @@ export default class Controller { clusterLogIndex: lib.LogIndex | null = null; clusterLogBuildInterval: ReturnType | null = null; + // Cache for mod portal requests + modPortalCache = new Map(); + static async bootstrap(config: lib.ControllerConfig) { let databaseDirectory = config.get("controller.database_directory"); await fs.ensureDir(databaseDirectory); @@ -288,7 +291,7 @@ export default class Controller { let tls_cert = this.config.get("controller.tls_certificate"); let tls_key = this.config.get("controller.tls_private_key"); - if (httpsPort && (!tls_cert || !tls_key)) { + if (httpsPort && (!tls_cert || !tls_key)) { throw new lib.StartupError( "tls_certificate and tls_private_key must be configure in order to use https_port" ); diff --git a/packages/lib/src/ModStore.ts b/packages/lib/src/ModStore.ts index e53fdb39..f7a6a3ca 100644 --- a/packages/lib/src/ModStore.ts +++ b/packages/lib/src/ModStore.ts @@ -2,15 +2,41 @@ import fs from "fs-extra"; import path from "path"; import { Static } from "@sinclair/typebox"; import { logger } from "./logging"; -import { ModInfo } from "./data"; +import { integerModVersion, ModInfo, ModPack, ModRecord } from "./data"; import TypedEventEmitter from "./TypedEventEmitter"; import { safeOutputFile } from "./file_ops"; +import { Writable } from "stream"; +import { ModPortalReleaseSchema, ModPortalDetailsSchema } from "./data/messages_mod"; export interface ModStoreEvents { /** A stored mod was created, updated or deleted */ change: (mod: ModInfo) => void; } +interface ModsInfoResponse { + pagination: any, + results: Array>, +} + +/** + * Interface describing the structure of the pagination info from the portal API + */ +interface ModPortalPagination { + count: number; + links: { next?: string; prev?: string; }; + page: number; + page_count: number; + page_size: number; +} + +/** + * Interface describing the structure of the portal API response for a single page + */ +interface ModPortalResponse { + pagination: ModPortalPagination; + results: Static[]; +} + export default class ModStore extends TypedEventEmitter { constructor( public modsDirectory: string, @@ -27,11 +53,6 @@ export default class ModStore extends TypedEventEmitter>, version: string, + username: string, token: string + ) { + const selectedRelease = releases.find(release => release.version === version); + + if (selectedRelease === undefined) { + logger.warn(`Mod release matching version ${version} not found.`); + return; + } + + try { + const url = new URL(`https://mods.factorio.com/${selectedRelease.download_url}`); + url.searchParams.set("username", username); + url.searchParams.set("token", token); + const tempFileName = `${this.modsDirectory}/${selectedRelease.file_name}.tmp`; + const finalFileName = `${this.modsDirectory}/${selectedRelease.file_name}`; + + await ModStore.downloadFile(url, tempFileName); + await fs.rename(tempFileName, finalFileName); + await this.loadFile(selectedRelease.file_name); + + } catch (err: any) { + logger.error(`Failed to download or load mod ${selectedRelease.file_name} (${version}): ${err.message}`); + // Attempt to clean up temp file if it exists + try { + const tempFileName = `${this.modsDirectory}/${selectedRelease.file_name}.tmp`; + await fs.unlink(tempFileName); + } catch (unlinkErr: any) { + if (unlinkErr.code !== "ENOENT") { // Ignore if file doesn't exist + logger.warn(`Failed to clean up temp file ${selectedRelease.file_name}.tmp: ${unlinkErr.message}`); + } + } + } + } + + private async downloadModsChunk( + modNames: string[], modVersions: string[], username: string, token: string, factorioVersion: string + ) { + const url = new URL("https://mods.factorio.com/api/mods"); + url.searchParams.set("page_size", "max"); + url.searchParams.set("version", factorioVersion); + const response = await fetch(url, { + method: "POST", + body: new URLSearchParams({ namelist: modNames.join(",") }), + }); + if (response.status !== 200) { + throw Error(`Fetch: ${url} returned ${response.status} ${response.statusText}`); + } + const mods = (await response.json() as ModsInfoResponse).results; + + 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`); + })); + } + + + /** + * + * downloads mods to the mod dir, will not download mods for which are already in the mod directory + * + * @param modMap a map of what mods to download and what version to download + * @param username factorio username used for auth to download mods + * @param token factorio token used for auth to download mods + * @param factorioVersion factorio version to filter for (mods are not guaranteed to work between Middle versions) + * + */ + async downloadMods(modMap: Map, username: string, token: string, factorioVersion: string = "1.1") { + const chunkSize = 500; + let futures: Promise[] = []; + modMap = new Map(modMap); + + // Truncate factorioVersion to major.minor (e.g., "1.1.100" -> "1.1") for API/builtin checks + const versionParts = factorioVersion.split("."); + if (versionParts.length > 2) { + factorioVersion = versionParts.slice(0, 2).join("."); + } + + const builtInMods = new Set(ModPack.getBuiltinModNames(factorioVersion)); + + // get rid of mods already downloaded, built-in, or core + modMap.forEach((modVersion, modName, map) => { + const filename = `${modName}_${modVersion}.zip`; + // Filter existing, built-in from getBuiltinModNames, and explicitly filter "core" + if (this.files.has(filename) || builtInMods.has(modName) || modName === "core") { + map.delete(modName); + } + }); + + const modNames = Array.from(modMap.keys()); + const modVersions = Array.from(modMap.values()); + for (let i = 0; i < modMap.size; i += chunkSize) { + futures.push( + this.downloadModsChunk(modNames.slice(i, i + chunkSize), modVersions.slice(i, i + chunkSize), + username, token, factorioVersion) + ); + } + await Promise.all(futures); + } + + static async downloadFile(url: string | URL, filePath: string) { + try { + const response = await fetch(url); + + if (!response.ok) { + throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`); + } + + const fileStream = fs.createWriteStream(filePath); + + // Check if the response body exists and pipe it to the file + if (response.body) { + const writer = Writable.toWeb(fileStream); + await response.body.pipeTo(writer); + } else { + throw new Error("Response body is missing."); + } + } catch (error) { + logger.error("Error downloading file:", error); + throw error; + } + } + + /** + * Fetch all mods from the Factorio Mod Portal for a specific Factorio version. + * + * Handles pagination automatically to retrieve the complete list. + * + * @param factorioVersion - The Factorio version to fetch mods for (e.g., "1.1"). + * @param pageSize - The page size for fetching mods. + * @param hide_deprecated - Whether to exclude deprecated mods. + * @returns An array of mod details. + */ + static async fetchAllModsFromPortal( + factorioVersion: string, + pageSize: number, + hide_deprecated: boolean = false + ): Promise[]> { + logger.info(`Fetching all mods for Factorio version ${factorioVersion}...`); + let currentPage = 1; + let allMods: Static[] = []; + let hasMorePages = true; + + while (hasMorePages) { + const url = new URL("https://mods.factorio.com/api/mods"); + url.searchParams.set("page_size", String(pageSize)); + url.searchParams.set("version", factorioVersion); + url.searchParams.set("page", String(currentPage)); + url.searchParams.set("hide_deprecated", String(hide_deprecated)); + + logger.verbose(`Fetching mod portal page ${currentPage}: ${url.toString()}`); + const response = await fetch(url.toString()); + + if (!response.ok) { + let errorMessage = `Mod portal fetch page ${currentPage} failed: `; + errorMessage += `${response.status} ${response.statusText}`; + try { + errorMessage += ` - ${await response.text()}`; + } catch (bodyError) { + logger.warn("Failed to read response body for failed portal request"); + } + throw new Error(errorMessage); + } + + const data = await response.json() as ModPortalResponse; + allMods = allMods.concat(data.results); + + hasMorePages = currentPage < data.pagination.page_count; + currentPage += 1; + } + logger.info(`Successfully fetched ${allMods.length} mods for Factorio version ${factorioVersion}.`); + return allMods; + } } diff --git a/packages/lib/src/config/definitions.ts b/packages/lib/src/config/definitions.ts index e7f7406d..fec7fae1 100644 --- a/packages/lib/src/config/definitions.ts +++ b/packages/lib/src/config/definitions.ts @@ -26,6 +26,8 @@ export interface ControllerConfigFields { "controller.default_mod_pack_id": number | null; "controller.default_role_id": number | null; "controller.autosave_interval": number; + "controller.mod_portal_cache_duration_minutes": number; + "controller.mod_portal_page_size": number; } /** @@ -197,6 +199,18 @@ export class ControllerConfig extends classes.Config { optional: true, initialValue: 1, }, + "controller.mod_portal_cache_duration_minutes": { + title: "Mod Portal Cache Duration", + description: "Duration in minutes to cache mod portal API responses.", + type: "number", + initialValue: 30, + }, + "controller.mod_portal_page_size": { + title: "Mod Portal Page Size", + description: "Maximum number of results per page when querying the Factorio mod portal API.", + type: "number", + initialValue: 1000, + }, }; } diff --git a/packages/lib/src/data/messages_mod.ts b/packages/lib/src/data/messages_mod.ts index 60fc6563..b5a4d17a 100644 --- a/packages/lib/src/data/messages_mod.ts +++ b/packages/lib/src/data/messages_mod.ts @@ -197,6 +197,77 @@ export class ModSearchRequest { }; } +// Define the structure for the latest release info from the portal +export const ModPortalReleaseSchema = Type.Object({ + version: Type.String(), + // Match the structure from ModStore's ModRelease/ModDetails + info_json: Type.Object({ factorio_version: Type.String() }), + released_at: Type.String(), // ISO 8601 date string + download_url: Type.String(), + file_name: Type.String(), + sha1: Type.String(), +}); + +// Define the structure for mod details returned by the portal API +// This should align with the ModDetails interface in ModStore.ts +export const ModPortalDetailsSchema = Type.Object({ + name: Type.String(), + title: Type.String(), + summary: Type.String(), + owner: Type.String(), + downloads_count: Type.Integer(), + category: Type.Optional(Type.String()), + score: Type.Optional(Type.Number()), + latest_release: Type.Optional(ModPortalReleaseSchema), + releases: Type.Optional(Type.Array(ModPortalReleaseSchema)), +}); + +export class ModPortalGetAllRequest { + declare ["constructor"]: typeof ModPortalGetAllRequest; + static type = "request" as const; + static src = "control" as const; + static dst = "controller" as const; + static permission = "core.mod.search_portal" as const; + + // Define allowed Factorio versions + static allowedVersions = [ + Type.Literal("0.13"), Type.Literal("0.14"), Type.Literal("0.15"), + Type.Literal("0.16"), Type.Literal("0.17"), Type.Literal("0.18"), + Type.Literal("1.0"), Type.Literal("1.1"), Type.Literal("2.0"), + ] as const; + + constructor( + public factorioVersion: Static, + public hide_deprecated?: boolean, + ) { } + + static jsonSchema = Type.Object({ + "factorioVersion": Type.Union([...this.allowedVersions]), // Use Union for validation + "hide_deprecated": Type.Optional(Type.Boolean()), + }); + + static fromJSON(json: Static) { + return new this(json.factorioVersion, json.hide_deprecated); + } + + // Define the Response class inline + static Response = class ModPortalGetAllResponse { + declare ["constructor"]: typeof ModPortalGetAllResponse; + constructor( + public mods: Static[], + ) { } + + static jsonSchema = Type.Object({ + mods: Type.Array(ModPortalDetailsSchema), + }); + + static fromJSON(json: Static) { + // No transformation needed if schema matches API structure + return new this(json.mods); + } + }; +} + export class ModDownloadRequest { declare ["constructor"]: typeof ModDownloadRequest; static type = "request" as const; @@ -284,3 +355,39 @@ export class ModUpdatesEvent { return new this(json.updates.map(update => ModInfo.fromJSON(update))); } } + +/** + * Request mods to be downloaded from the Factorio mod portal to the controller. + * + * The controller handles the actual download process asynchronously. + * Requires Factorio credentials to be configured on the controller if the portal + * requires authentication for downloads. + * + * @param name - Name of the mod to download. + * @param version - Version of the mod to download. + * @param factorioVersion - Factorio version context for the download. + */ +export class ModPortalDownloadRequest { + declare ["constructor"]: typeof ModPortalDownloadRequest; + static type = "request" as const; + static src = "control" as const; + static dst = "controller" as const; + static permission = "core.mod.download_from_portal" as const; + static Response = ModInfo; + + constructor( + public name: string, + public version: string, + public factorioVersion: string, + ) { } + + static jsonSchema = Type.Object({ + "name": Type.String(), + "version": Type.String(), + "factorioVersion": Type.String(), + }); + + static fromJSON(json: Static) { + return new this(json.name, json.version, json.factorioVersion); + } +} diff --git a/packages/lib/src/link/messages.ts b/packages/lib/src/link/messages.ts index 7c0edbda..200e6c79 100644 --- a/packages/lib/src/link/messages.ts +++ b/packages/lib/src/link/messages.ts @@ -97,10 +97,12 @@ export const dataClasses: (RequestClass | EventClass) mod.ModGetRequest, mod.ModListRequest, mod.ModSearchRequest, + mod.ModPortalGetAllRequest, mod.ModDownloadRequest, mod.ModDeleteRequest, mod.ModPackUpdatesEvent, mod.ModUpdatesEvent, + mod.ModPortalDownloadRequest, user.PermissionListRequest, user.RoleListRequest, diff --git a/packages/lib/src/permissions.ts b/packages/lib/src/permissions.ts index ff5443b8..c347e23e 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", @@ -381,7 +387,7 @@ definePermission({ }); definePermission({ name: "core.mod.upload", - title: "Upload mod", + title: "Upload mods", description: "Upload mods to the controller.", }); definePermission({ @@ -395,6 +401,12 @@ definePermission({ title: "Delete mod", description: "Delete mods stored on the controller.", }); +definePermission({ + name: "core.mod.download_from_portal", + title: "Download mods from portal", + description: "Allow downloading mods directly from the Factorio mod portal to the controller.", + grantByDefault: true, +}); definePermission({ name: "core.permission.list", diff --git a/packages/web_ui/src/components/ModsPage.tsx b/packages/web_ui/src/components/ModsPage.tsx index eeec0224..38924d07 100644 --- a/packages/web_ui/src/components/ModsPage.tsx +++ b/packages/web_ui/src/components/ModsPage.tsx @@ -1,8 +1,12 @@ -import React, { Fragment, useState, useContext } from "react"; +import React, { Fragment, useState, useContext, useEffect, useMemo } 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 { + Button, Form, Input, Modal, Popconfirm, Space, Table, Typography, Upload, Alert, Select, Tooltip, notification, +} from "antd"; +import { + ImportOutlined, PlusOutlined, SearchOutlined, DownloadOutlined, +} from "@ant-design/icons"; +import { Static } from "@sinclair/typebox"; import * as lib from "@clusterio/lib"; @@ -21,6 +25,12 @@ import UploadButton from "./UploadButton"; const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare; +// Define the allowed Factorio versions based on the lib schema +type FactorioVersion = Static<(typeof lib.ModPortalGetAllRequest)["allowedVersions"][number]>; + +// Type alias for the mod structure derived from the library response +type ModPortalModType = InstanceType["mods"][number]; + function ImportModPackButton() { let control = useContext(ControlContext); let navigate = useNavigate(); @@ -129,6 +139,375 @@ 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("2.0"); + + // State for all mods fetched from backend + const [allMods, setAllMods] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // State for client-side pagination and sorting + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(10); + const [sort, setSort] = useState("name"); + const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc"); + + // Function to handle download from Mod Portal to Controller + const handleControllerDownload = ( + modName: string, + modTitle: string | undefined, + modVersion: string | undefined, + portalFactorioVersion: FactorioVersion, + ) => { + if (modVersion) { + control.send( + new lib.ModPortalDownloadRequest( + modName, + modVersion, + portalFactorioVersion + ) + ).then(() => { + notification.success({ + message: "Download started", + description: `${modTitle || modName} v${ + modVersion + } is being downloaded to the controller.`, + }); + }).catch( + notifyErrorHandler("Error starting mod download") + ); + } + }; + + // Fetch all mods from backend when modal opens or version changes + useEffect(() => { + if (!open || !factorioVersion) { + setAllMods([]); // Clear mods if modal closed or no version + return; + } + + setLoading(true); + setError(null); + let canceled = false; + + control.send( + new lib.ModPortalGetAllRequest(factorioVersion) + ).then((response: any) => { + if (canceled) { return; } + setAllMods(response.mods || []); + setLoading(false); + }).catch(err => { + if (canceled) { return; } + notifyErrorHandler("Error fetching mods from portal")(err); + setError(err); + setAllMods([]); + setLoading(false); + }); + + // eslint-disable-next-line consistent-return + return () => { + canceled = true; + }; + // Re-fetch when modal opens or factorio version changes + }, [open, factorioVersion, control]); + + + // Memoize the filtered, sorted, and paginated mods (logic remains the same) + const displayedMods = useMemo(() => { + let filtered = allMods; + + // Apply search filter (case-insensitive) + if (searchText) { + const lowerSearchText = searchText.toLowerCase(); + filtered = filtered.filter(mod => mod.name.toLowerCase().includes(lowerSearchText) + || mod.title.toLowerCase().includes(lowerSearchText) + ); + } + + // Apply sorting + if (sort && sortOrder) { + filtered = [...filtered].sort((a, b) => { + let aValue: any = a[sort as keyof ModPortalModType] ?? ""; + let bValue: any = b[sort as keyof ModPortalModType] ?? ""; + + // Handle specific types if necessary (e.g., numbers, dates) + if (sort === "downloads_count") { + aValue = a.downloads_count ?? 0; + bValue = b.downloads_count ?? 0; + return sortOrder === "asc" ? aValue - bValue : bValue - aValue; + } + + // Default string comparison + const comparison = strcmp(String(aValue), String(bValue)); + return sortOrder === "asc" ? comparison : -comparison; + }); + } + + // Apply pagination + const startIndex = (page - 1) * pageSize; + return filtered.slice(startIndex, startIndex + pageSize); + + }, [allMods, searchText, sort, sortOrder, page, pageSize]); + + // Calculate total results based on the filtered list *before* pagination (logic remains the same) + const totalResults = useMemo(() => { + let filtered = allMods; + if (searchText) { + const lowerSearchText = searchText.toLowerCase(); + filtered = filtered.filter(mod => mod.name.toLowerCase().includes(lowerSearchText) + || mod.title.toLowerCase().includes(lowerSearchText) + ); + } + return filtered.length; + }, [allMods, searchText]); + + // Update search text and factorio version from form + const handleSearch = (changedValues: any, allValues: any) => { + const nameValue = allValues.name; + const versionValue = allValues.factorioVersion as FactorioVersion | undefined; + + // Only trigger state updates if values actually changed + if (nameValue !== searchText) { + setSearchText(nameValue || ""); + } + if (versionValue && versionValue !== factorioVersion) { + setFactorioVersion(versionValue); + setAllMods([]); // Clear mods when version changes, useEffect will fetch new ones + setLoading(true); // Show loading indicator immediately + } + // Reset page only if search text or version changed + if (nameValue !== searchText || (versionValue && versionValue !== factorioVersion)) { + setPage(1); + } + }; + + // Update pagination and sorting state when table changes (logic remains the same) + const handleTableChange = ( + pagination: any, + filters: any, + sorter: any + ) => { + if (pagination.current !== page) { + setPage(pagination.current); + } + if (pagination.pageSize !== pageSize) { + setPageSize(pagination.pageSize); + setPage(1); // Reset to first page if page size changes + } + + // Update sort state if sorter exists and has changed + if (sorter.field && (sorter.field !== sort || sorter.order !== (sortOrder === "asc" ? "ascend" : "descend"))) { + setSort(sorter.field); + setSortOrder(sorter.order === "ascend" ? "asc" : "desc"); + setPage(1); // Reset to first page on sort change + } else if (!sorter.field && sort !== "name") { // Reset only if not already default + // Clear sort if column header clicked without order - default to name ascending + setSort("name"); + setSortOrder("asc"); + setPage(1); + } + }; + + // Helper to get antd sort order format (logic remains the same) + const getColumnSortOrder = (columnKey: string): "ascend" | "descend" | undefined => { + if (sort === columnKey) { + return sortOrder === "asc" ? "ascend" : "descend"; + } + return undefined; + }; + + return <> + + { setOpen(false); }} + width={1000} + footer={[ + , + ]} + > +
+ + + + + + +
+ + {/* Display error message if fetching failed */} + {error && } + + record.name} + loading={loading} + onChange={handleTableChange} + pagination={{ + current: page, + pageSize: pageSize, + total: totalResults, + showSizeChanger: true, + pageSizeOptions: ["10", "20", "50", "100"], + }} + expandable={{ + expandedRowRender: record => ( +
+

Summary: {record.summary ?? "N/A"}

+

Downloads: {record.downloads_count?.toLocaleString() ?? "N/A"}

+

Latest Release:

+ {record.latest_release ? ( +
    +
  • Version: {record.latest_release.version ?? "N/A"}
  • +
  • + Factorio Version: { + record.latest_release.info_json?.factorio_version ?? "N/A" + } +
  • +
  • + Released: { + record.latest_release.released_at + ? new Date(record.latest_release.released_at).toLocaleString() + : "N/A" + } +
  • +
+ ) :

No release information available.

} + {account.hasPermission("core.mod.download_from_portal") && record.latest_release && ( + + )} +
+ ), + }} + columns={[ + // Columns remain largely the same + { + title: "Name", + dataIndex: "name", + key: "name", + sorter: true, + sortOrder: getColumnSortOrder("name"), + }, + { + title: "Title", + dataIndex: "title", + key: "title", + sorter: true, + sortOrder: getColumnSortOrder("title"), + }, + { + title: "Author", + dataIndex: "owner", + key: "owner", + sorter: true, + sortOrder: getColumnSortOrder("owner"), + }, + { + title: "Downloads", + dataIndex: "downloads_count", + key: "downloads_count", + sorter: true, + sortOrder: getColumnSortOrder("downloads_count"), + align: "right", + render: (count) => count?.toLocaleString() ?? "N/A", + }, + { + title: "Latest Version", + key: "version", + render: (_, record) => record.latest_release?.version ?? "N/A", + }, + { + title: "Actions", + key: "actions", + align: "center", + render: (_, record) => ( + + + + + + + {account.hasPermission("core.mod.download_from_portal") && record.latest_release && ( + +