Search mods clientside

This commit is contained in:
Danielv123 committed 2025-04-08 22:34:32 +02:00
1 parent 3bf0d48641
commit 600a5d5ce1
5 files changed
+326 -207

No files matched your search

+14 -10
View File
@@ -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}`);
}
}
+68 -81
View File
@@ -45,6 +45,28 @@ interface ModsInfoResponse {
results: Array<ModDetails>,
}
/**
* 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<keyof ModStoreEvents, ModStoreEvents> {
constructor(
public modsDirectory: string,
@@ -434,95 +456,60 @@ export default class ModStore extends TypedEventEmitter<keyof ModStoreEvents, Mo
}
/**
* Search for mods on the Factorio mod portal
* Fetch all mods from the Factorio Mod Portal for a specific Factorio version.
*
* @param query Search query string
* @param factorioVersion Factorio version to filter for
* @param page Page number (1-based)
* @param pageSize Number of results per page
* @param sort Field to sort by (name, title, downloads, etc.)
* @param sortOrder Sort order (asc or desc)
* @returns Search results from the mod portal
* Handles pagination automatically to retrieve the complete list.
*
* @param factorioVersion - The Factorio version to fetch mods for (e.g., "1.1").
* @param hide_deprecated - Whether to exclude deprecated mods.
* @returns An array of mod details.
*/
static async searchModPortal(
query: string,
static async fetchAllModsFromPortal(
factorioVersion: string,
page: number,
pageSize: number = 10,
sort?: string,
sortOrder: string = "asc"
) {
// eslint-disable-next-line
logger.info(`Searching mod portal for ${query} with factorio version ${factorioVersion} on page ${page} with page size ${pageSize} and sort ${sort} and sort order ${sortOrder}`);
hide_deprecated: boolean = false
): Promise<ModDetails[]> {
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,
};
}
}
+47 -61
View File
@@ -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<typeof ModPortalGetAllRequest.allowedVersions[number]>,
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<typeof this.jsonSchema>) {
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<typeof ModPortalDetailsSchema>[],
) { }
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<typeof this.jsonSchema>) {
return new this(
json.queryIssues,
json.pageCount,
json.resultCount,
json.results
);
// No transformation needed if schema matches API structure
return new this(json.mods);
}
};
}
+1 -1
View File
@@ -97,7 +97,7 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
mod.ModGetRequest,
mod.ModListRequest,
mod.ModSearchRequest,
mod.ModPortalSearchRequest,
mod.ModPortalGetAllRequest,
mod.ModDownloadRequest,
mod.ModDeleteRequest,
mod.ModPackUpdatesEvent,
+196 -54
View File
@@ -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<any[]>([]);
const [loading, setLoading] = useState(false);
const [factorioVersion, setFactorioVersion] = useState<FactorioVersion>("2.0");
// State for all mods fetched from backend
const [allMods, setAllMods] = useState<ModPortalMod[]>([]);
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 [totalResults, setTotalResults] = useState(0);
const [totalPages, setTotalPages] = useState(0);
const [sort, setSort] = useState<string>("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 <>
<Button icon={<SearchOutlined />} onClick={() => { setOpen(true); }}>Search</Button>
<Modal
title="Search Mods"
title="Search Mod Portal"
open={open}
onCancel={() => { setOpen(false); }}
width={800}
@@ -221,27 +325,48 @@ function SearchModsButton() {
<Button key="close" onClick={() => { setOpen(false); }}>
Close
</Button>,
<Button key="search" type="primary" onClick={handleSearch}>
Search
</Button>,
// Remove explicit search button as form updates trigger search/filter
]}
>
<Form
form={form}
layout="vertical"
onFinish={handleSearch}
initialValues={{ factorioVersion: "2.0" }}
onValuesChange={handleSearch} // Trigger update on any form change
initialValues={{ factorioVersion: factorioVersion, name: searchText }}
>
<Form.Item name="name" label="Name or Title">
<Input placeholder="Enter mod name or title" />
<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">
<Input placeholder="e.g. 1.1" />
<Form.Item
name="factorioVersion"
label="Factorio Version"
>
<Select>
{/* Map allowed versions to Select Options */}
<Select.Option value="2.0">2.0</Select.Option>
<Select.Option value="1.1">1.1</Select.Option>
<Select.Option value="1.0">1.0</Select.Option>
<Select.Option value="0.18">0.18</Select.Option>
<Select.Option value="0.17">0.17</Select.Option>
<Select.Option value="0.16">0.16</Select.Option>
<Select.Option value="0.15">0.15</Select.Option>
<Select.Option value="0.14">0.14</Select.Option>
<Select.Option value="0.13">0.13</Select.Option>
</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={modResults}
dataSource={displayedMods}
rowKey={record => 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 => (
<div>
<p><strong>Summary:</strong> {record.summary ?? "N/A"}</p>
<p><strong>Downloads:</strong> {record.downloads_count ?? "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.factorio_version ?? "N/A"
record.latest_release.info_json?.factorio_version ?? "N/A"
}
</li>
<li>
@@ -275,21 +400,28 @@ function SearchModsButton() {
</li>
</ul>
) : <p>No release information available.</p>}
{/* Download button logic remains the same, assuming backend handles downloads */}
{account.hasPermission("core.mod.download") && record.latest_release && (
<Button
onClick={() => {
control.send(
new lib.ModDownloadRequest(record.name, record.latest_release.version)
).then((streamId: string) => {
let url = new URL(webRoot, document.location.origin);
url.pathname += `api/stream/${streamId}`;
document.location = url.href;
}).catch(
notifyErrorHandler("Error downloading mod")
);
if (record.latest_release?.version) {
control.send(
new lib.ModDownloadRequest(
record.name,
record.latest_release.version
)
)
.then((streamId: string) => {
let url = new URL(webRoot, document.location.origin);
url.pathname += `api/stream/${streamId}`;
document.location = url.href;
})
.catch(
notifyErrorHandler("Error downloading mod")
);
}
}}
// Disable button if version is missing
disabled={!record.latest_release.version}
disabled={!record.latest_release?.version}
>
Download Latest Version
</Button>
@@ -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",