mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-13 23:14:53 +09:00
Add mod portal search
This commit is contained in:
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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user