diff --git a/packages/controller/src/ControlConnection.ts b/packages/controller/src/ControlConnection.ts index 315e6b92..0ce1d46c 100644 --- a/packages/controller/src/ControlConnection.ts +++ b/packages/controller/src/ControlConnection.ts @@ -98,7 +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.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)); @@ -515,19 +515,23 @@ export default class ControlConnection extends BaseConnection { }; } - async handleModPortalSearchRequest(request: lib.ModPortalSearchRequest) { + + /** + * 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) { try { - return lib.ModStore.searchModPortal( - request.query, + const mods = await lib.ModStore.fetchAllModsFromPortal( request.factorioVersion, - request.page, - request.pageSize || 10, - request.sort, - request.sortOrder || "asc" + request.hide_deprecated ); + // The Response class is defined inline within the Request class + return new lib.ModPortalGetAllRequest.Response(mods); } catch (error: any) { - logger.error(`Error searching mod portal: ${error}`); - throw new lib.RequestError(`Failed to search mod portal: ${error.message}`); + logger.error(`Error fetching all mods from portal (${request.factorioVersion}): ${error.message}`); + // Propagate a user-friendly error back to the client + throw new lib.RequestError(`Portal mod fetch failed: ${error.message}`); } } diff --git a/packages/lib/src/ModStore.ts b/packages/lib/src/ModStore.ts index 0c3b8fb6..6698a431 100644 --- a/packages/lib/src/ModStore.ts +++ b/packages/lib/src/ModStore.ts @@ -45,6 +45,28 @@ interface ModsInfoResponse { 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: ModDetails[]; +} + +const MOD_PORTAL_API_URL = "https://mods.factorio.com/api/mods"; +const MOD_PORTAL_MAX_PAGE_SIZE = 10000; // Or largest allowed by API + export default class ModStore extends TypedEventEmitter { constructor( public modsDirectory: string, @@ -434,95 +456,60 @@ export default class ModStore extends TypedEventEmitter { + logger.info(`Fetching all mods for Factorio version ${factorioVersion}...`); + let currentPage = 1; + let allMods: ModDetails[] = []; + let hasMorePages = true; - // Construct the URL for the Factorio mod portal API - const url = new URL("https://mods.factorio.com/api/mods"); + try { + while (hasMorePages) { + const url = new URL(MOD_PORTAL_API_URL); + url.searchParams.set("page_size", String(MOD_PORTAL_MAX_PAGE_SIZE)); + url.searchParams.set("version", factorioVersion); + url.searchParams.set("page", String(currentPage)); + url.searchParams.set("hide_deprecated", String(hide_deprecated)); - // Add query parameters - url.searchParams.set("page", page.toString()); - url.searchParams.set("page_size", pageSize.toString()); - url.searchParams.set("version", factorioVersion); + logger.verbose(`Fetching mod portal page ${currentPage}: ${url.toString()}`); + const response = await fetch(url.toString()); - if (query) { - url.searchParams.set("q", query); - } + if (!response.ok) { + let errorDetail = ""; + try { + errorDetail = await response.text(); + } catch (bodyError) { + logger.warn("Failed to read response body for failed portal request"); + } + let errorMessage = `Mod portal fetch page ${currentPage} failed: `; + errorMessage += `${response.status} ${response.statusText}`; + if (errorDetail) { + errorMessage += ` - ${errorDetail}`; + } + throw new Error(errorMessage); + } - // Handle sorting - if (sort) { - let sortField = sort; - // Map frontend sort fields to API sort fields - if (sort === "name") { - sortField = "name"; - } else if (sort === "title") { - sortField = "title"; - } else if (sort === "downloads") { - sortField = "downloads_count"; - } else if (sort === "updated") { - sortField = "updated_at"; + const data = await response.json() as ModPortalResponse; + allMods = allMods.concat(data.results); + + hasMorePages = currentPage < data.pagination.page_count; + currentPage += 1; } - - url.searchParams.set("sort", sortField); - url.searchParams.set("sort_order", sortOrder); + logger.info(`Successfully fetched ${allMods.length} mods for Factorio version ${factorioVersion}.`); + return allMods; + } catch (err: any) { + logger.error(err); + throw new Error(err); } - - // Make the request to the Factorio mod portal - const response = await fetch(url); - - if (!response.ok) { - throw new Error(`Failed to fetch from mod portal: ${response.status} ${response.statusText}`); - } - - const data = await response.json() as ModsInfoResponse; - logger.info(`Mod portal search results: ${JSON.stringify(data)}`); - - // Transform the response to match our expected format - const results = data.results.map(mod => { - // 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 3dd61955..e495bef1 100644 --- a/packages/lib/src/data/messages_mod.ts +++ b/packages/lib/src/data/messages_mod.ts @@ -197,87 +197,73 @@ export class ModSearchRequest { }; } -export class ModPortalSearchRequest { - declare ["constructor"]: typeof ModPortalSearchRequest; +// Define the structure for the latest release info from the portal +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 +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()), // Add optional category + score: Type.Optional(Type.Number()), // Add optional score + latest_release: Type.Optional(ModPortalReleaseSchema), + // Add releases array if needed: 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; + static permission = "core.mod.search_portal" as const; // Reuse search permission for now + + // 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 query: string, - public factorioVersion: string, - public page: number, - public pageSize?: number, - public sort?: string, - public sortOrder?: string, + public factorioVersion: Static, + public hide_deprecated?: boolean, ) { } 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()), + "factorioVersion": Type.Union([...this.allowedVersions]), // Use Union for validation + "hide_deprecated": Type.Optional(Type.Boolean()), }); static fromJSON(json: Static) { - return new this(json.query, json.factorioVersion, json.page, json.pageSize, json.sort, json.sortOrder); + return new this(json.factorioVersion, json.hide_deprecated); } - static Response = class Response { + // Define the Response class inline + static Response = class ModPortalGetAllResponse { + declare ["constructor"]: typeof ModPortalGetAllResponse; 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, - }, - }[], + public mods: Static[], ) { } 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(), - }), - }), - ), + mods: Type.Array(ModPortalDetailsSchema), }); static fromJSON(json: Static) { - return new this( - json.queryIssues, - json.pageCount, - json.resultCount, - json.results - ); + // No transformation needed if schema matches API structure + return new this(json.mods); } }; } diff --git a/packages/lib/src/link/messages.ts b/packages/lib/src/link/messages.ts index b1c17826..2a275125 100644 --- a/packages/lib/src/link/messages.ts +++ b/packages/lib/src/link/messages.ts @@ -97,7 +97,7 @@ export const dataClasses: (RequestClass | EventClass) mod.ModGetRequest, mod.ModListRequest, mod.ModSearchRequest, - mod.ModPortalSearchRequest, + mod.ModPortalGetAllRequest, mod.ModDownloadRequest, mod.ModDeleteRequest, mod.ModPackUpdatesEvent, diff --git a/packages/web_ui/src/components/ModsPage.tsx b/packages/web_ui/src/components/ModsPage.tsx index 3b0654c4..644d6352 100644 --- a/packages/web_ui/src/components/ModsPage.tsx +++ b/packages/web_ui/src/components/ModsPage.tsx @@ -1,9 +1,10 @@ -import React, { Fragment, useState, useContext, useEffect } 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 { Button, Form, Input, Modal, Popconfirm, Space, Table, Typography, Upload, Alert, Select } from "antd"; import ImportOutlined from "@ant-design/icons/ImportOutlined"; import PlusOutlined from "@ant-design/icons/PlusOutlined"; import SearchOutlined from "@ant-design/icons/SearchOutlined"; +import { Static } from "@sinclair/typebox"; import * as lib from "@clusterio/lib"; @@ -22,6 +23,9 @@ 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]>; + function ImportModPackButton() { let control = useContext(ControlContext); let navigate = useNavigate(); @@ -130,45 +134,71 @@ function CreateModPackButton() { ; } +// Define a basic type for the mod data we expect back from the new backend request +// This should ideally match the structure returned by the backend. +// We reuse the ModPortalMod structure name for convenience, but it's fetched via backend. +interface ModPortalMod { + name: string; + title: string; + owner: string; + summary: string; + downloads_count: number; + category: string; + score?: number; + latest_release?: { + download_url: string; + file_name: string; + info_json: { factorio_version: string; }; + released_at: string; + version: string; + sha1: string; + }; +} + 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 [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 [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 + // Fetch all mods from backend when modal opens or version changes useEffect(() => { - if (!open) { return; } + if (!open || !factorioVersion) { + setAllMods([]); // Clear mods if modal closed or no version + return; + } setLoading(true); + setError(null); let canceled = false; - control.send(new lib.ModPortalSearchRequest( - searchText, - factorioVersion, - page, - pageSize, - sort, - sortOrder - )).then(response => { + // Use the hypothetical new backend request + control.send( + // Factorio version state now matches the required type + new lib.ModPortalGetAllRequest(factorioVersion) + ).then((response: any) => { if (canceled) { return; } - - setModResults(response.results); - setTotalResults(response.resultCount); - setTotalPages(response.pageCount); + // *** Backend Change Needed: Ensure the response has a 'mods' array *** + setAllMods(response?.mods || []); // Added optional chaining setLoading(false); - }).catch(error => { + }).catch(err => { if (canceled) { return; } + notifyErrorHandler("Error fetching mods from portal")(err); + setError(err); + setAllMods([]); setLoading(false); }); @@ -176,15 +206,80 @@ function SearchModsButton() { return () => { canceled = true; }; - }, [open, searchText, factorioVersion, page, pageSize, sort, sortOrder, control]); + // Re-fetch when modal opens or factorio version changes + }, [open, factorioVersion, control]); - const handleSearch = () => { - const values = form.getFieldsValue(); - setSearchText(values.name || ""); - setFactorioVersion(values.factorioVersion || "1.1"); - setPage(1); // Reset to first page on new search + + // 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 ModPortalMod] ?? ""; + let bValue: any = b[sort as keyof ModPortalMod] ?? ""; + + // 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, @@ -195,14 +290,23 @@ function SearchModsButton() { } if (pagination.pageSize !== pageSize) { setPageSize(pagination.pageSize); + setPage(1); // Reset to first page if page size changes } - if (sorter.field && sorter.order) { + // 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"; @@ -213,7 +317,7 @@ function SearchModsButton() { return <> { setOpen(false); }} width={800} @@ -221,27 +325,48 @@ function SearchModsButton() { , - , + // Remove explicit search button as form updates trigger search/filter ]} >
- - + + - - + + + {/* Display error message if fetching failed */} + {error && } + record.name} loading={loading} onChange={handleTableChange} @@ -250,20 +375,20 @@ function SearchModsButton() { pageSize: pageSize, total: totalResults, showSizeChanger: true, - pageSizeOptions: ["10", "20", "50"], + pageSizeOptions: ["10", "20", "50", "100"], }} expandable={{ expandedRowRender: record => (

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

-

Downloads: {record.downloads_count ?? "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.factorio_version ?? "N/A" + record.latest_release.info_json?.factorio_version ?? "N/A" }
  • @@ -275,21 +400,28 @@ function SearchModsButton() {
) :

No release information available.

} + {/* Download button logic remains the same, assuming backend handles downloads */} {account.hasPermission("core.mod.download") && record.latest_release && ( @@ -298,6 +430,7 @@ function SearchModsButton() { ), }} columns={[ + // Columns remain largely the same { title: "Name", dataIndex: "name", @@ -319,6 +452,15 @@ function SearchModsButton() { 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",