Merge pull request #712 from O2theC/auto-mod-update - basic mod portal integration

Add mod portal integration and mod downloads to the web interface
This commit is contained in:
Daniel authored and GitHub committed 2025-04-13 21:52:07 +02:00
commit f21a3f45bf
10 files changed
+1834 -15

No files matched your search

+4
View File
@@ -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:
@@ -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<lib.ModInfo> {
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);
}
+4 -1
View File
@@ -96,6 +96,9 @@ export default class Controller {
clusterLogIndex: lib.LogIndex | null = null;
clusterLogBuildInterval: ReturnType<typeof setInterval> | null = null;
// Cache for mod portal requests
modPortalCache = new Map<string, { timestamp: number, data: any[] }>();
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"
);
+208 -8
View File
@@ -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<Static<typeof ModPortalDetailsSchema>>,
}
/**
* 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<typeof ModPortalDetailsSchema>[];
}
export default class ModStore extends TypedEventEmitter<keyof ModStoreEvents, ModStoreEvents> {
constructor(
public modsDirectory: string,
@@ -27,11 +53,6 @@ export default class ModStore extends TypedEventEmitter<keyof ModStoreEvents, Mo
return mod;
}
hasMod(name: string, version: string, sha1?: string): boolean {
const mod = this.files.get(ModInfo.filename(name, version));
return Boolean(mod && (!sha1 || sha1 !== mod.sha1));
}
mods() { return this.files.values(); }
static async loadFile(modsDirectory: string, file: string) {
@@ -44,8 +65,7 @@ export default class ModStore extends TypedEventEmitter<keyof ModStoreEvents, Mo
async loadFile(file: string) {
let modInfo = await ModStore.loadFile(this.modsDirectory, file);
this.files.set(modInfo.filename, modInfo);
this.emit("change", modInfo);
this.addMod(modInfo);
return modInfo;
}
@@ -134,4 +154,184 @@ export default class ModStore extends TypedEventEmitter<keyof ModStoreEvents, Mo
return new this(modsDirectory, files);
}
private async downloadModFromReleases(releases: Array<Static<typeof ModPortalReleaseSchema>>, 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<string, string>, username: string, token: string, factorioVersion: string = "1.1") {
const chunkSize = 500;
let futures: Promise<void>[] = [];
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<Static<typeof ModPortalDetailsSchema>[]> {
logger.info(`Fetching all mods for Factorio version ${factorioVersion}...`);
let currentPage = 1;
let allMods: Static<typeof ModPortalDetailsSchema>[] = [];
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;
}
}
+14
View File
@@ -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<ControllerConfigFields> {
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,
},
};
}
+107
View File
@@ -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<typeof ModPortalGetAllRequest.allowedVersions[number]>,
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<typeof this.jsonSchema>) {
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<typeof ModPortalDetailsSchema>[],
) { }
static jsonSchema = Type.Object({
mods: Type.Array(ModPortalDetailsSchema),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
// 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<typeof this.jsonSchema>) {
return new this(json.name, json.version, json.factorioVersion);
}
}
+2
View File
@@ -97,10 +97,12 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
mod.ModGetRequest,
mod.ModListRequest,
mod.ModSearchRequest,
mod.ModPortalGetAllRequest,
mod.ModDownloadRequest,
mod.ModDeleteRequest,
mod.ModPackUpdatesEvent,
mod.ModUpdatesEvent,
mod.ModPortalDownloadRequest,
user.PermissionListRequest,
user.RoleListRequest,
+13 -1
View File
@@ -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",
+387 -5
View File
@@ -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<typeof lib.ModPortalGetAllRequest.Response>["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<FactorioVersion>("2.0");
// State for all mods fetched from backend
const [allMods, setAllMods] = useState<ModPortalModType[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<Error | null>(null);
// State for client-side pagination and sorting
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [sort, setSort] = useState<string>("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 <>
<Button icon={<SearchOutlined />} onClick={() => { setOpen(true); }}>Search</Button>
<Modal
title="Search Mod Portal"
open={open}
onCancel={() => { setOpen(false); }}
width={1000}
footer={[
<Button key="close" onClick={() => { setOpen(false); }}>
Close
</Button>,
]}
>
<Form
form={form}
layout="horizontal"
labelCol={{ span: 6 }}
wrapperCol={{ span: 18 }}
onValuesChange={handleSearch}
initialValues={{ factorioVersion: factorioVersion, name: searchText }}
>
<Form.Item name="name" label="Filter by Name or Title">
<Input placeholder="Start typing to filter..." />
</Form.Item>
<Form.Item
name="factorioVersion"
label="Factorio Version"
>
<Select>
{lib.ModPortalGetAllRequest.allowedVersions.map((literalSchema: any) => (
<Select.Option
key={literalSchema.const}
value={literalSchema.const}
>
{literalSchema.const}
</Select.Option>
)).reverse()}
</Select>
</Form.Item>
</Form>
{/* Display error message if fetching failed */}
{error && <Alert
message="Error Fetching Mods"
description={error.message}
type="error"
showIcon
style={{ marginBottom: 16 }}
/>}
<Table
dataSource={displayedMods}
rowKey={record => record.name}
loading={loading}
onChange={handleTableChange}
pagination={{
current: page,
pageSize: pageSize,
total: totalResults,
showSizeChanger: true,
pageSizeOptions: ["10", "20", "50", "100"],
}}
expandable={{
expandedRowRender: record => (
<div>
<p><strong>Summary:</strong> {record.summary ?? "N/A"}</p>
<p><strong>Downloads:</strong> {record.downloads_count?.toLocaleString() ?? "N/A"}</p>
<p><strong>Latest Release:</strong></p>
{record.latest_release ? (
<ul>
<li>Version: {record.latest_release.version ?? "N/A"}</li>
<li>
Factorio Version: {
record.latest_release.info_json?.factorio_version ?? "N/A"
}
</li>
<li>
Released: {
record.latest_release.released_at
? new Date(record.latest_release.released_at).toLocaleString()
: "N/A"
}
</li>
</ul>
) : <p>No release information available.</p>}
{account.hasPermission("core.mod.download_from_portal") && record.latest_release && (
<Button
onClick={() => {
handleControllerDownload(
record.name,
record.title,
record.latest_release!.version,
factorioVersion
);
}}
disabled={!record.latest_release?.version}
>
Download Latest Version
</Button>
)}
</div>
),
}}
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) => (
<Space>
<Tooltip title="Open in Factorio Mod Portal">
<a
href={`https://mods.factorio.com/mod/${record.name}`}
target="_blank"
rel="noopener noreferrer"
>
<Button type="text">
<img
src="https://mods.factorio.com/static/favicon.ico"
alt="Factorio Mod Portal"
/>
</Button>
</a>
</Tooltip>
{account.hasPermission("core.mod.download_from_portal") && record.latest_release && (
<Tooltip title="Download to Controller">
<Button
type="text"
icon={<DownloadOutlined />}
disabled={!record.latest_release.version}
onClick={() => {
handleControllerDownload(
record.name,
record.title,
record.latest_release!.version,
factorioVersion
);
}}
/>
</Tooltip>
)}
</Space>
),
},
]}
/>
</Modal>
</>;
}
export default function ModsPage() {
let account = useAccount();
let control = useContext(ControlContext);
@@ -223,7 +602,10 @@ export default function ModsPage() {
},
})}
/>
<SectionHeader title="Stored Mods" extra={uploadButton} />
<SectionHeader title="Stored Mods" extra={<Space>
<SearchModsButton />
{uploadButton}
</Space>} />
<Upload.Dragger
className="save-list-dragger"
File diff suppressed because it is too large. Load diff