import React, { Fragment, memo, useCallback, useEffect, useContext, useRef, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { FieldData } from "rc-field-form/lib/interface"; import { Button, Card, ColorPicker, Checkbox, Col, ConfigProvider, Descriptions, Form, Input, Pagination, Popconfirm, Row, Table, Tag, Typography, Select, Skeleton, Space, Spin, Switch, Modal, Tooltip, TablePaginationConfig, Alert, notification, } from "antd"; import { ExportOutlined, FileUnknownOutlined, FileExclamationOutlined, FileSyncOutlined, CloseOutlined, DeleteOutlined, ToolOutlined, PlusOutlined, CloudSyncOutlined, CloudDownloadOutlined, LikeOutlined, } from "@ant-design/icons"; import type { SorterResult, FilterValue, TableCurrentDataSource } from "antd/es/table/interface"; import * as lib from "@clusterio/lib"; import ControlContext from "./ControlContext"; import { useAccount } from "../model/account"; import { useModPack } from "../model/mod_pack"; import { useMods } from "../model/mods"; import { useClipboard } from "../util/clipboard"; import notify, { notifyErrorHandler } from "../util/notify"; import PageHeader from "./PageHeader"; import PageLayout from "./PageLayout"; import SectionHeader from "./SectionHeader"; import ModDetails from "./ModDetails"; import { InputPartialVersion } from "./InputVersion"; import Link from "./Link"; const { logger } = lib; const { Text } = Typography; const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare; type ModChange = { type: "name" | "description" name?: never, scope?: never, value: string, } | { type: "factorioVersion", name?: never, scope?: never, value: lib.PartialVersion, } | { type: "settings.set" | "settings.delete", name: string, scope: "startup" | "runtime-global" | "runtime-per-user", value: lib.ModSetting, } | { type: "mods.set" | "mods.delete", name: string, scope?: never, value: lib.ModRecord, } ; type ModResult = { name: string; versions: lib.ModInfo[]; }; type SearchModsTableProps = { modPack: lib.ModPack; changes: ModChange[]; onChange: (change: ModChange) => void; }; function SearchModsTable(props: SearchModsTableProps) { const control = useContext(ControlContext); const [searchText, setSearchText] = useState(""); const [modResultSort, setModResultSort] = useState("title"); const [modResultSortOrder, setModResultSortOrder] = useState("asc"); const [modResults, setModResults] = useState([]); const [modResultPage, setModResultPage] = useState(1); const [modResultPageSize, setModResultPageSize] = useState(10); const [modResultCount, setModResultCount] = useState(2); const [modResultSelectedVersion, setModResultSelectedVersion] = useState>(new Map()); const [factorioVersion, setFactorioVersion] = useState(null); // Get a valid factorio version useEffect(() => { try { setFactorioVersion(lib.normaliseMajorMinorVersion(props.modPack.factorioVersion)); } catch (err) { setFactorioVersion(null); } }, [props.modPack.factorioVersion]); useEffect(() => { let canceled = false; if (factorioVersion) { control.send(new lib.ModSearchRequest( searchText, factorioVersion, modResultPage, modResultPageSize, modResultSort, modResultSortOrder, )).then(response => { if (canceled) { return; } // In React < v18 this causes 3 renders and a partial state necessitating the use // of || 0 on modResultSelectedVersion.get() calls. Remove when updating React. setModResults(response.results); setModResultSelectedVersion(new Map(response.results.map(({ name, versions }) => [name, 0]))); setModResultCount(response.resultCount); }); } return () => { canceled = true; }; }, [searchText, factorioVersion, modResultPageSize, modResultPage, modResultSort, modResultSortOrder]); function modResultPageChanged(page: number, pageSize: number) { setModResultPage(page); setModResultPageSize(pageSize); } function modResultsTableChanged( pagination: TablePaginationConfig, filters: Record, sorter: SorterResult|SorterResult[], extra: TableCurrentDataSource, ) { if (extra.action === "sort") { if (sorter instanceof Array) { sorter = sorter[0]; } if (sorter.order) { const mapping = { ascend: "asc", descend: "desc", }; setModResultSort(String(sorter.columnKey)); setModResultSortOrder(mapping[sorter.order]); } else { setModResultSort(undefined); setModResultSortOrder(undefined); } } } function actions(mod: lib.ModInfo|lib.ModRecord) { return {(!props.modPack.mods.has(mod.name) || props.modPack.mods.get(mod.name)?.version !== mod.version) && { props.onChange({ type: "mods.set", name: mod.name, value: { name: mod.name, enabled: true, version: mod.version, sha1: mod.sha1 }, }); }} >{props.modPack.mods.has(mod.name) ? "change" : "add"} } {props.modPack.mods.has(mod.name) && { props.onChange({ type: "mods.delete", name: mod.name, value: { name: mod.name, enabled: false, version: mod.version, sha1: mod.sha1 }, }); }} >remove } ; } return <> { let search = event.target.value.trim(); setSearchText(search); setModResultPage(1); }} /> `${total} results`} showSizeChanger={true} size="small" />} /> "No results"}> result.versions[0].title, defaultSortOrder: "ascend", sorter: true, }, { title: "Author", key: "author", render: (_, result) => result.versions[0].author, responsive: ["lg"], sorter: true, }, { title: "Version", key: "version", align: "right", render: (_, result) =>
record.name} loading={loading} pagination={{ defaultPageSize: 10, showSizeChanger: true, pageSizeOptions: ["10", "20", "50", "100"], showTotal: (total: number) => ( props.checkForUpdates ? `${total} Updates` : `${total} Missing Dependencies` ), }} columns={[ { title: "Name", dataIndex: "name", key: "name", sorter: true, }, { title: "Title", dataIndex: "title", key: "title", sorter: true, }, { title: "Author", dataIndex: "author", key: "author", sorter: true, }, { title: "Selected Version", dataIndex: "version", key: "version", }, ]} /> ; } type ModsTableProps = { modPack: lib.ModPack; changes: ModChange[]; onChange: (change: ModChange) => void; onRevert: (change: ModChange) => void; builtInModNames: string[]; }; function ModsTable(props: ModsTableProps) { const account = useAccount(); const [modInfos] = useMods(); const [showAddMods, setShowAddMods] = useState(false); const deletedMods: Map = new Map(); const changedMods: Map = new Map(); for (let change of props.changes) { if (change.name) { if (props.modPack.mods.has(change.name)) { if (change.type === "mods.set") { changedMods.set(change.name, change.value); } } else if (change.type === "mods.delete") { deletedMods.set(change.name, change.value); } } } let mods = [...props.modPack.mods.values(), ...deletedMods.values()].map( (mod: lib.ModRecord): lib.ModRecord => { if (props.builtInModNames.includes(mod.name)) { return { ...mod, enabled: mod.enabled ?? false, }; } const modInfo = modInfos.get(`${mod.name}_${mod.version}`); if (!modInfo) { return { ...mod, enabled: mod.enabled ?? false, error: "missing", }; } else if (mod.sha1 && modInfo.sha1 !== mod.sha1) { return { ...mod, enabled: mod.enabled ?? false, error: "bad_checksum", }; } return { ...mod, enabled: mod.enabled ?? false, info: modInfo, }; } ); for (const mod of mods) { if (mod.enabled && mod.info) { mod.warning = mod.info.checkDependencySatisfaction(mods.filter(m => m.enabled)); try { const packFactorioVersion = lib.normaliseMajorMinorVersion(props.modPack.factorioVersion); const modFactorioVersion = mod.info.factorioVersion; if (packFactorioVersion !== modFactorioVersion) { mod.warning = "wrong_factorio_version"; } } catch { // Hit if the mod or mod pack version is not a valid api version mod.warning = "wrong_factorio_version"; } } } mods = lib.applyModRecordAdvisories(mods, lib.getInstalledModUpdates(mods, modInfos.values())); async function fixDependencyIssues(mod: lib.ModRecord) { if (!mod.info) { throw new Error("Mod record has no info"); // Should be unreachable } if (mod.info.factorioVersion === "0.12") { throw new Error("Factorio version 0.12 not supported by mod portal"); } let hasFailure = false; const enabledMods = mods.filter(m => m.enabled); const modInfoArray = [...modInfos.values()]; for (const dependency of mod.info.dependencies) { const reason = dependency.checkUnsatisfiedReason(enabledMods); if (reason === undefined) { continue; // No issues for this dependency } if (reason === "incompatible") { // Disable the incompatible mod props.onChange({ type: "mods.set", name: dependency.name, value: { ...props.modPack.mods.get(dependency.name)!, enabled: false }, }); continue; } if (props.builtInModNames.includes(dependency.name)) { // If builtin mod then enable it props.onChange({ type: "mods.set", name: dependency.name, value: { ...props.modPack.mods.get(dependency.name)!, enabled: true }, }); continue; } // Find a suitable version to add const candidate = modInfoArray .filter(info => (info.name === dependency.name && info.factorioVersion === mod.info!.factorioVersion && (!dependency.version || dependency.version.testVersion(info.version)) )) .reduce((max, cur) => ( max && max.integerVersion > cur.integerVersion ? max : cur ), undefined); if (candidate) { props.onChange({ type: "mods.set", name: candidate.name, value: { name: candidate.name, version: candidate.version, sha1: candidate.sha1, enabled: true, }, }); } else { hasFailure = true; } } return hasFailure; } function actions(mod: lib.ModRecord) { return {!deletedMods.has(mod.name) && !props.builtInModNames.includes(mod.name) && { props.onChange({ type: "mods.delete", name: mod.name, value: mod, }); }} >remove} {(deletedMods.has(mod.name) || changedMods.has(mod.name)) && { props.onRevert({ type: deletedMods.has(mod.name) ? "mods.delete" : "mods.set", name: mod.name, value: mod, }); }} >revert} {(mod.warning && mod.warning !== "wrong_factorio_version") && { if (await fixDependencyIssues(mod)) { notify("Failed to automatically fix all issues", "warning"); } }} >fix issues} ; } return <> {account.hasAllPermission("core.mod.search_portal", "core.mod.download_from_portal") && m.enabled && (m.error || m.warning && ["wrong_version", "missing_dependency"].includes(m.warning)) )} />} {account.hasAllPermission("core.mod.search_portal", "core.mod.download_from_portal") && } } /> {showAddMods && } onClick={() => setShowAddMods(false)} />} style={{ marginBottom: 8 }} > }
mod.enabled).map(mod => mod.name), getCheckboxProps(mod) { return { disabled: deletedMods.has(mod.name) }; }, onChange(selectedRowKeys, selectedRows) { function setEnabled(modNames: string[], enabled: boolean) { for (let name of modNames) { props.onChange({ type: "mods.set", name, value: { ...props.modPack.mods.get(name)!, enabled }, }); } } const added = selectedRowKeys.filter( key => !props.modPack.mods.get(key as string)?.enabled ) as string[]; const removed = [...props.modPack.mods.values()].filter( mod => mod.enabled && selectedRowKeys.indexOf(mod.name) === -1 ).map(mod => mod.name); setEnabled(added, true); setEnabled(removed, false); }, }} columns={[ { title: "Name", key: "name", render: (_, mod: lib.ModRecord) => { const recommendedBy = mod.advisories ?.filter(advisory => advisory.type === "recommended_dependency") .map(advisory => advisory.sourceModName) ?? []; const update = mod.advisories ?.find(advisory => advisory.type === "update_available"); return <> {mod.error === "missing" && {" "} } {mod.error === "bad_checksum" && {" "} } {mod.warning === "incompatible" && {" "} } {mod.warning === "missing_dependency" && {" "} } {mod.warning === "wrong_version" && {" "} } {mod.warning === "wrong_factorio_version" && {" "} } {recommendedBy.length > 0 && {" "} } {update?.type === "update_available" && {" "} } {mod.info?.title || mod.name} ; }, defaultSortOrder: "ascend", sorter: (a, b) => strcmp(a.name, b.name), }, { title: "Version", dataIndex: "version", sorter: (a, b) => strcmp(a.version, b.version), }, { title: "Action", key: "action", responsive: ["lg"], render: mod => actions(mod), }, ]} expandable={{ expandedRowRender: mod => m.enabled)} actions={actions} />, expandedRowClassName: () => "no-expanded-padding", }} dataSource={mods} pagination={false} rowKey="name" rowClassName={mod => { if (deletedMods.has(mod.name)) { return "deleted"; } if (changedMods.has(mod.name)) { return "changed"; } return ""; }} /> ; } // Returns true if a change record with the given fields exist function hasChange(changes: ModChange[], field:Partial) { return changes .some(change => Object.entries(field) .every(([key, value]) => (change as any)[key] === value)); } // picks out the value property from settings. function pickValue(map: Map): [string, lib.ModSetting["value"]][] { return [...map].map(([name, value]) => [name, value.value]); } function groupToMap(array: any[], fn: (...args:any) => any) { const map = new Map(); for (let index = 0; index < array.length; index++) { let key = fn(array[index], index, array); if (!map.has(key)) { map.set(key, []); } map.get(key).push(array[index]); } return map; } function InputColor(props: { value: lib.ModSettingColor, onChange: (value: lib.ModSettingColor) => void, }) { // In certain edge cases it's possible a value for this mod setting does not exist // or is of the wrong type. Fallback to black if this is the case. function isColor(input: unknown) { if (typeof input !== "object" || input === null) { return false; } return "r" in input && "g" in input && "b" in input && "a" in input; } const value = isColor(props.value) ? props.value : { r: 0, g: 0, b: 0, a: 1 }; return { const rgb = color.toRgb(); props.onChange({ r: rgb.r / 255, g: rgb.g / 255, b: rgb.b / 255, a: rgb.a, }); }} />; } type SettingsTableFieldProps = { name: string; type: string; scope: string; allowedValues: any; locale: Map; changed: boolean; } const SettingsTableField = memo((props: SettingsTableFieldProps) => { const name = props.name; const isBoolean = props.type === "bool-setting"; const isColor = props.type === "color-setting"; const isNumber = ["int-setting", "double-setting"].includes(props.type); const isSelect = Boolean(props.allowedValues); let input; if (isBoolean) { input = ; } else if (isColor) { const PartialInputColor = InputColor as () => React.JSX.Element; input = ; } else if (isSelect) { const options = Object.values(props.allowedValues).map(value => ({ value, label: props.locale.get(`string-mod-setting.${name}-${value}`) || value, })); input = ; } return {input} ; }); type SettingsTableProps = { modPack: lib.ModPack; prototypes: any; locale: any; changes: ModChange[]; onChange: (change: ModChange) => void; onRevert: (change: ModChange) => void; }; function SettingsTable(props: SettingsTableProps) { const [mods] = useMods(); const modsInPack = new Set([...props.modPack.mods.values()].map(mod => `${mod.name}_${mod.version}`)); const modTitles = new Map([...mods.values()] .filter(mod => modsInPack.has(mod.id)) .map(mod => [mod.name, mod.title]) ); const types = ["bool-setting", "int-setting", "double-setting", "string-setting", "color-setting"]; let prototypes = Object.entries(props.prototypes) .filter(([type, _]) => types.includes(type)) .flatMap(([_, settingPrototypes]: [string, any]) => Object.values(settingPrototypes)) ; function controls( scope: "startup"|"runtime-global"|"runtime-per-user", storedFields: [string, lib.ModSetting["value"]][] ) { let fields = new Map(prototypes .filter(p => p.setting_type === scope) .sort((a, b) => strcmp(a.mod, b.mod) || strcmp(a.order || "", b.order || "") || strcmp(a.name, b.name)) .map(p => [p.name, p]) ); for (let [name, value] of storedFields) { if (!fields.has(name)) { let type; if (typeof value === "boolean") { type = "bool-setting"; } else if (typeof value === "number") { type = "double-setting"; } else if (typeof value === "string") { type = "string-setting"; } else if (typeof value === "object" && "r" in value) { type = "color-setting"; } else { throw Error(`Unhandled setting type ${typeof value}`); } fields.set(name, { mod: "Unknown Fields", name, setting_type: scope, type }); } } let fieldsByMod = groupToMap([...fields.values()], prototype => prototype.mod); return [...fieldsByMod].map(([mod, fieldsForMod]) => {props.locale.get(`mod-name.${mod}`) || modTitles.get(mod) || mod} {[...fieldsForMod].map( prototype => )} ); } return <> Run export data on an instance to update available fields. Startup {controls("startup", pickValue(props.modPack.settings["startup"]))} Map These only apply for new saves. {controls("runtime-global", pickValue(props.modPack.settings["runtime-global"]))} Per Player Default settings for new players, these only apply for new saves. {controls("runtime-per-user", pickValue(props.modPack.settings["runtime-per-user"]))} ; } function CopyButton(props: { content: string }) { let clipboard = useClipboard(); let [copiedVisible, setCopiedVisible] = useState(false); let copiedTimeout = useRef|undefined>(); useEffect(() => () => { clearTimeout(copiedTimeout.current); setCopiedVisible(false); }, []); let denied = clipboard.readPermissionState === "denied"; return ; } function ExportButton(props: { modPack: lib.ModPack }) { let [open, setOpen] = useState(false); function close() { setOpen(false); } let exportString = ""; if (open) { exportString = props.modPack.toModPackString(); } return <> } > {exportString} ; } function useExportedAsset(modPack: lib.ModPack | undefined, asset: "settings"|"locale"): Map | any { let [assetData, setAssetData] = useState|any>(asset === "locale" ? new Map() : {}); let assetFilename: string | undefined; if (modPack instanceof lib.ModPack && modPack.exportManifest?.assets[asset]) { assetFilename = modPack.exportManifest.assets[asset]; } useEffect(() => { async function load() { if (!assetFilename) { return; } let response = await fetch(`${staticRoot}${assetFilename}`); if (response.ok) { let data = await response.json(); setAssetData(asset === "locale" ? new Map(data) : data); } else { logger.error(`Error loading mod pack asset "${asset}" (${response.status}): ${await response.text()}`); } } load().catch(notifyErrorHandler(`Loading mod pack asset "${asset}" failed`)); }, [assetFilename]); return assetData; } function applyModPackChanges(modPack: lib.ModPack, changes: ModChange[]) { let modifiedModPack = modPack.shallowClone(); for (let change of changes) { // Only modify fields that change to reduce re-renders if ( ["mods.set", "mods.delete", "factorioVersion"].includes(change.type) && modifiedModPack.mods === modPack.mods ) { modifiedModPack.mods = new Map(modifiedModPack.mods); } if ( ["settings.set", "settings.delete"].includes(change.type) && change.scope && modifiedModPack.settings[change.scope] === modPack.settings[change.scope] ) { if (modifiedModPack.settings === modPack.settings) { modifiedModPack.settings = { ...modPack.settings }; } modifiedModPack.settings[change.scope] = new Map(modifiedModPack.settings[change.scope]); } if (change.type === "name") { modifiedModPack.name = change.value; } else if (change.type === "description") { modifiedModPack.description = change.value; } else if (change.type === "mods.set") { modifiedModPack.mods.set(change.name, change.value as lib.ModRecord); } else if (change.type === "mods.delete") { modifiedModPack.mods.delete(change.name); } else if (change.type === "settings.set") { modifiedModPack.settings[change.scope].set(change.name, change.value); } else if (change.type === "settings.delete") { modifiedModPack.settings[change.scope].delete(change.name); } else if (change.type === "factorioVersion") { const newBuiltinMods = lib.ModPack.getBuiltinMods(change.value); for (const newBuiltinMod of newBuiltinMods) { const builtinMod = modifiedModPack.mods.get(newBuiltinMod.name); if (builtinMod) { newBuiltinMod.enabled = builtinMod.enabled; } modifiedModPack.mods.set(newBuiltinMod.name, newBuiltinMod); } const oldBuiltinMods = lib.ModPack.getBuiltinMods(modifiedModPack.factorioVersion); for (const oldBuiltinMod of oldBuiltinMods) { if (!newBuiltinMods.some(builtinMod => builtinMod.name === oldBuiltinMod.name)) { modifiedModPack.mods.delete(oldBuiltinMod.name); } } modifiedModPack.factorioVersion = change.value; } else { throw new Error(`Unknown change type ${change.type}`); } } return modifiedModPack; } export default function ModPackViewPage() { let account = useAccount(); let navigate = useNavigate(); let control = useContext(ControlContext); let params = useParams(); let modPackId = Number(params.id); const [form] = Form.useForm(); const [modPack, synced] = useModPack(modPackId); let prototypes: any = useExportedAsset(modPack, "settings"); let locale: Map = useExportedAsset(modPack, "locale"); let [changes, setChanges] = useState([]); let syncTimeout = useRef|undefined>(); const modifiedModPack = modPack ? applyModPackChanges(modPack, changes) : undefined; useEffect(() => () => { clearTimeout(syncTimeout.current); }); const pushChange = useCallback((change: ModChange) => { setChanges(oldChanges => { const newChanges = [...oldChanges]; if ( newChanges.length && newChanges.slice(-1)[0].type === change.type && newChanges.slice(-1)[0].name === change.name && newChanges.slice(-1)[0].scope === change.scope ) { newChanges.splice(-1, 1, change); } else { newChanges.push(change); } return newChanges; }); }, []); const revertChange = useCallback((change: ModChange) => { setChanges(oldChanges => { const newChanges = [...oldChanges]; const index = newChanges.findLastIndex(reference => ( change.type === reference.type && change.name === reference.name )); if (index === -1) { logger.error(`Unable to revert ${change.type} ${change.name}: change not found`); return oldChanges; } newChanges.splice(index, 1); return newChanges; }); }, []); function syncPack(fields: FieldData[]) { for (let field of fields) { if (!field.touched || field.validating || (field.errors?.length??false)) { continue; } if (["name", "description", "factorioVersion"].includes(field.name[0])) { const name = field.name[0] as "name" | "description" | "factorioVersion"; if (modifiedModPack![name] !== field.value) { pushChange({ type: name, value: field.value }); } } else if (["startup", "runtime-global", "runtime-per-user"].includes(field.name[0])) { let [scope, settingName]: ["startup"|"runtime-global"|"runtime-per-user", string] = field.name; let value = field.value; if (typeof modPack!.settings[scope].get(settingName)?.value === "number") { value = Number(value); if (Number.isNaN(value)) { continue; } } if (modifiedModPack!.settings[scope].get(settingName)?.value !== value) { pushChange({ type: "settings.set", scope, name: settingName, value: { value }}); } } } } let nav = [{ name: "Mods", path: "/mods" }, { name: "Mod Packs" }, { name: modPack?.name ?? String(modPackId) }]; if (!modifiedModPack || !modPack) { if (!synced) { return ; } return

Mod pack with id {modPackId} was not found on the controller.

; } return {account.hasPermission("core.mod_pack.delete") && { if (modPack.id !== undefined) { control.send( new lib.ModPackDeleteRequest(modPack.id) ).then(() => { navigate("/mods"); }).catch(notifyErrorHandler("Error deleting mod pack")); } }} > } } />
{ clearTimeout(syncTimeout.current); if (typeof changedFields[0].value === "boolean") { syncPack(allFields); } else { // Defer sync if this is not a checkbox to make the UI responsive. syncTimeout.current = setTimeout(syncPack.bind(undefined, allFields), 500); } }} initialValues={{ name: modPack.name, description: modPack.description, factorioVersion: modPack.factorioVersion, "startup": Object.fromEntries(pickValue(modPack.settings["startup"])), "runtime-global": Object.fromEntries(pickValue(modPack.settings["runtime-global"])), "runtime-per-user": Object.fromEntries(pickValue(modPack.settings["runtime-per-user"])), }} > {modifiedModPack.mods.size} form.setFieldValue("factorioVersion", value)} className={hasChange(changes, { type: "factorioVersion" }) ? "changed" : ""} /> Inspect exported data Icon reference
You have unsaved changes ; }