Add mod portal search

This commit is contained in:
Danielv123 committed 2025-03-15 23:13:33 +01:00
1 parent 05790966db
commit 2ae3f9a600
6 files changed
+409 -11

No files matched your search

@@ -98,6 +98,7 @@ export default class ControlConnection extends BaseConnection {
this.handle(lib.ModGetRequest, this.handleModGetRequest.bind(this));
this.handle(lib.ModListRequest, this.handleModListRequest.bind(this));
this.handle(lib.ModSearchRequest, this.handleModSearchRequest.bind(this));
this.handle(lib.ModPortalSearchRequest, this.handleModPortalSearchRequest.bind(this));
this.handle(lib.ModDeleteRequest, this.handleModDeleteRequest.bind(this));
this.handle(lib.LogSetSubscriptionsRequest, this.handleLogSetSubscriptionsRequest.bind(this));
this.handle(lib.LogQueryRequest, this.handleLogQueryRequest.bind(this));
@@ -514,6 +515,22 @@ export default class ControlConnection extends BaseConnection {
};
}
async handleModPortalSearchRequest(request: lib.ModPortalSearchRequest) {
try {
return lib.ModStore.searchModPortal(
request.query,
request.factorioVersion,
request.page,
request.pageSize || 10,
request.sort,
request.sortOrder || "asc"
);
} catch (error: any) {
logger.error(`Error searching mod portal: ${error}`);
throw new lib.RequestError(`Failed to search mod portal: ${error.message}`);
}
}
async handleModDeleteRequest(request: lib.ModDeleteRequest) {
await this._controller.modStore.deleteMod(request.name, request.version);
}
+116 -9
View File
@@ -1,6 +1,6 @@
import fs from "fs-extra";
import path from "path";
import { Static} from "@sinclair/typebox";
import { Static } from "@sinclair/typebox";
import { logger } from "./logging";
import { integerModVersion, ModInfo, ModPack, ModRecord } from "./data";
import TypedEventEmitter from "./TypedEventEmitter";
@@ -36,7 +36,8 @@ interface ModDetails {
downloads_count: number,
category: string,
score?: number,
releases: Array<ModRelease>,
releases?: Array<ModRelease>,
latest_release?: ModRelease,
}
interface ModsInfoResponse {
@@ -196,9 +197,14 @@ export default class ModStore extends TypedEventEmitter<keyof ModStoreEvents, Mo
const versions = new Map<string, string>();
mods.forEach((mod) => {
const modName = mod.name;
let latestVersion = ModStore.getLatestVersionFromReleases(mod.releases);
if (latestVersion !== undefined) { versions.set(modName, latestVersion); }
if (mod.releases) {
let latestVersion = ModStore.getLatestVersionFromReleases(mod.releases);
if (latestVersion !== undefined) { versions.set(modName, latestVersion); }
} else if (mod.latest_release) {
versions.set(modName, mod.latest_release.version);
} else {
logger.warn(`Mod ${modName} has no releases or latest_release`);
}
});
return versions;
}
@@ -217,8 +223,9 @@ export default class ModStore extends TypedEventEmitter<keyof ModStoreEvents, Mo
if (factorioVersion.split(".").length >= 3) {
factorioVersion = factorioVersion.slice(0, factorioVersion.lastIndexOf("."));
}
modNames = modNames.filter((modName, index, array) => !(modName in ModPack.getBuiltinModNames(factorioVersion))
);
const builtinMods = ModPack.getBuiltinModNames(factorioVersion);
modNames = modNames.filter(modName => !(modName in builtinMods));
const chunkSize = 500;
let versions = new Map<string, string>();
@@ -269,8 +276,16 @@ export default class ModStore extends TypedEventEmitter<keyof ModStoreEvents, Mo
}
const mods = (await response.json() as ModsInfoResponse).results;
await Promise.all(mods.map((mod) => this.downloadModFromReleases(mod.releases,
modVersions[modNames.indexOf(mod.name)], username, token)));
await Promise.all(mods.map((mod) => {
if (mod.releases) {
return this.downloadModFromReleases(mod.releases,
modVersions[modNames.indexOf(mod.name)], username, token);
} else if (mod.latest_release) {
return this.downloadModFromReleases([mod.latest_release],
modVersions[modNames.indexOf(mod.name)], username, token);
}
throw new Error(`Mod ${mod.name} has no releases or latest_release`);
}));
}
@@ -418,4 +433,96 @@ export default class ModStore extends TypedEventEmitter<keyof ModStoreEvents, Mo
});
}
/**
* Search for mods on the Factorio mod portal
*
* @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
*/
static async searchModPortal(
query: string,
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}`);
// Construct the URL for the Factorio mod portal API
const url = new URL("https://mods.factorio.com/api/mods");
// Add query parameters
url.searchParams.set("page", page.toString());
url.searchParams.set("page_size", pageSize.toString());
url.searchParams.set("version", factorioVersion);
if (query) {
url.searchParams.set("q", query);
}
// 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";
}
url.searchParams.set("sort", sortField);
url.searchParams.set("sort_order", sortOrder);
}
// 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,
};
}
}
+85
View File
@@ -197,6 +197,91 @@ export class ModSearchRequest {
};
}
export class ModPortalSearchRequest {
declare ["constructor"]: typeof ModPortalSearchRequest;
static type = "request" as const;
static src = "control" as const;
static dst = "controller" as const;
static permission = "core.mod.search_portal" as const;
constructor(
public query: string,
public factorioVersion: string,
public page: number,
public pageSize?: number,
public sort?: string,
public sortOrder?: string,
) { }
static jsonSchema = Type.Object({
"query": Type.String(),
"factorioVersion": Type.String(),
"page": Type.Integer(),
"pageSize": Type.Optional(Type.Integer()),
"sort": Type.Optional(Type.String()),
"sortOrder": Type.Optional(Type.String()),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.query, json.factorioVersion, json.page, json.pageSize, json.sort, json.sortOrder);
}
static Response = class Response {
constructor(
public queryIssues: string[],
public pageCount: number,
public resultCount: number,
public results: {
name: string,
title: string,
summary: string,
owner: string,
downloads_count: number,
latest_release: {
version: string,
factorio_version: string,
released_at: string,
download_url: string,
file_name: string,
sha1: string,
},
}[],
) { }
static jsonSchema = Type.Object({
"queryIssues": Type.Array(Type.String()),
"pageCount": Type.Integer(),
"resultCount": Type.Integer(),
"results": Type.Array(
Type.Object({
"name": Type.String(),
"title": Type.String(),
"summary": Type.String(),
"owner": Type.String(),
"downloads_count": Type.Number(),
"latest_release": Type.Object({
"version": Type.String(),
"factorio_version": Type.String(),
"released_at": Type.String(),
"download_url": Type.String(),
"file_name": Type.String(),
"sha1": Type.String(),
}),
}),
),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(
json.queryIssues,
json.pageCount,
json.resultCount,
json.results
);
}
};
}
export class ModDownloadRequest {
declare ["constructor"]: typeof ModDownloadRequest;
static type = "request" as const;
+1
View File
@@ -97,6 +97,7 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
mod.ModGetRequest,
mod.ModListRequest,
mod.ModSearchRequest,
mod.ModPortalSearchRequest,
mod.ModDownloadRequest,
mod.ModDeleteRequest,
mod.ModPackUpdatesEvent,
+6
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",
+184 -2
View File
@@ -1,8 +1,9 @@
import React, { Fragment, useState, useContext } from "react";
import React, { Fragment, useState, useContext, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { Button, Form, Input, Modal, Popconfirm, Space, Table, Typography, Upload } from "antd";
import ImportOutlined from "@ant-design/icons/ImportOutlined";
import PlusOutlined from "@ant-design/icons/PlusOutlined";
import SearchOutlined from "@ant-design/icons/SearchOutlined";
import * as lib from "@clusterio/lib";
@@ -129,6 +130,184 @@ function CreateModPackButton() {
</>;
}
function SearchModsButton() {
const control = useContext(ControlContext);
const account = useAccount();
const [open, setOpen] = useState(false);
const [form] = Form.useForm();
const [searchText, setSearchText] = useState("");
const [factorioVersion, setFactorioVersion] = useState("1.1");
const [modResults, setModResults] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [totalResults, setTotalResults] = useState(0);
const [totalPages, setTotalPages] = useState(0);
const [sort, setSort] = useState<string>("name");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
// Search mods when search parameters change
useEffect(() => {
if (!open) { return; }
setLoading(true);
let canceled = false;
control.send(new lib.ModPortalSearchRequest(
searchText,
factorioVersion,
page,
pageSize,
sort,
sortOrder
)).then(response => {
if (canceled) { return; }
setModResults(response.results);
setTotalResults(response.resultCount);
setTotalPages(response.pageCount);
setLoading(false);
}).catch(error => {
if (canceled) { return; }
setLoading(false);
});
return () => {
canceled = true;
};
}, [open, searchText, factorioVersion, page, pageSize, sort, sortOrder, control]);
const handleSearch = () => {
const values = form.getFieldsValue();
setSearchText(values.name || "");
setFactorioVersion(values.factorioVersion || "1.1");
setPage(1); // Reset to first page on new search
};
const handleTableChange = (
pagination: any,
filters: any,
sorter: any
) => {
if (pagination.current !== page) {
setPage(pagination.current);
}
if (pagination.pageSize !== pageSize) {
setPageSize(pagination.pageSize);
}
if (sorter.field && sorter.order) {
setSort(sorter.field);
setSortOrder(sorter.order === "ascend" ? "asc" : "desc");
}
};
return <>
<Button icon={<SearchOutlined />} onClick={() => { setOpen(true); }}>Search</Button>
<Modal
title="Search Mods"
open={open}
onCancel={() => { setOpen(false); }}
width={800}
footer={[
<Button key="close" onClick={() => { setOpen(false); }}>
Close
</Button>,
<Button key="search" type="primary" onClick={handleSearch}>
Search
</Button>,
]}
>
<Form
form={form}
layout="vertical"
onFinish={handleSearch}
initialValues={{ factorioVersion: "2.0" }}
>
<Form.Item name="name" label="Name or Title">
<Input placeholder="Enter mod name or title" />
</Form.Item>
<Form.Item name="factorioVersion" label="Factorio Version">
<Input placeholder="e.g. 1.1" />
</Form.Item>
</Form>
<Table
dataSource={modResults}
rowKey={record => record.name}
loading={loading}
onChange={handleTableChange}
pagination={{
current: page,
pageSize: pageSize,
total: totalResults,
showSizeChanger: true,
pageSizeOptions: ["10", "20", "50"],
}}
expandable={{
expandedRowRender: record => (
<div>
<p><strong>Summary:</strong> {record.summary}</p>
<p><strong>Downloads:</strong> {record.downloads_count}</p>
<p><strong>Latest Release:</strong></p>
<ul>
<li>Version: {record.latest_release.version}</li>
<li>Factorio Version: {record.latest_release.info_json.factorio_version}</li>
<li>Released: {new Date(record.latest_release.released_at).toLocaleString()}</li>
</ul>
{account.hasPermission("core.mod.download") && (
<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")
);
}}
>
Download Latest Version
</Button>
)}
</div>
),
}}
columns={[
{
title: "Name",
dataIndex: "name",
key: "name",
sorter: true,
sortOrder: sort === "name" ? (sortOrder === "asc" ? "ascend" : "descend") : undefined,
},
{
title: "Title",
dataIndex: "title",
key: "title",
sorter: true,
sortOrder: sort === "title" ? (sortOrder === "asc" ? "ascend" : "descend") : undefined,
},
{
title: "Author",
dataIndex: "owner",
key: "owner",
sorter: true,
sortOrder: sort === "owner" ? (sortOrder === "asc" ? "ascend" : "descend") : undefined,
},
{
title: "Latest Version",
key: "version",
render: (_, record) => record.latest_release.version,
},
]}
/>
</Modal>
</>;
}
export default function ModsPage() {
let account = useAccount();
let control = useContext(ControlContext);
@@ -223,7 +402,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"