diff --git a/packages/lib/src/data/ModPack.ts b/packages/lib/src/data/ModPack.ts index 4968980d..33251845 100644 --- a/packages/lib/src/data/ModPack.ts +++ b/packages/lib/src/data/ModPack.ts @@ -12,7 +12,7 @@ import ModInfo, { ModDependencyUnsatisfiedReason } from "./ModInfo"; import { PartialVersion, PartialVersionSchema, integerPartialVersion, - SourceVersion, SourceVersionSchema, normaliseFullVersion, + SourceVersion, SourceVersionSchema, normaliseFullVersion, integerSourceVersion, } from "./version"; @@ -32,6 +32,10 @@ export interface ModSetting { value: boolean | number | string | ModSettingColor; } +export type ModRecordAdvisory = + | { type: "recommended_dependency", sourceModName: string } + | { type: "update_available", version: SourceVersion }; + const ModSettingJsonSchema = Type.Object({ "value": Type.Union([Type.Boolean(), Type.Number(), Type.String(), ModSettingColor]), }); @@ -56,6 +60,86 @@ export interface ModRecord { warning?: ModDependencyUnsatisfiedReason | "wrong_factorio_version", /** Used inside packages\web_ui\src\components\ModPackViewPage.tsx when there is no error. */ info?: ModInfo, + /** Non-blocking recommendations displayed by the Web UI. */ + advisories?: ModRecordAdvisory[], +} + +type AnnotatedModRecord = ModRecord & { advisories: ModRecordAdvisory[] }; + +/** + * Finds newer versions already installed on the controller for mods in a pack. + */ +export function getInstalledModUpdates(mods: ModRecord[], installedMods: Iterable) { + const updates = new Map(); + + for (const mod of mods) { + if (!mod.info) { + continue; + } + + for (const installedMod of installedMods) { + if ( + installedMod.name !== mod.name + || installedMod.factorioVersion !== mod.info.factorioVersion + || installedMod.integerVersion <= integerSourceVersion(mod.version) + ) { + continue; + } + + const existingUpdate = updates.get(mod.name); + if (!existingUpdate || installedMod.integerVersion > integerSourceVersion(existingUpdate)) { + updates.set(mod.name, installedMod.version); + } + } + } + + return updates; +} + +export function applyModRecordAdvisories( + mods: ModRecord[], + availableUpdates: ReadonlyMap = new Map(), +) { + const annotatedMods: AnnotatedModRecord[] = mods.map(mod => ({ ...mod, advisories: [] })); + const modsByName = new Map(annotatedMods.map(mod => [mod.name, mod])); + + for (const sourceMod of annotatedMods) { + if (!sourceMod.enabled || !sourceMod.info) { + continue; + } + + for (const dependency of sourceMod.info.dependencies) { + if (!dependency.recommended) { + continue; + } + + const targetMod = modsByName.get(dependency.name); + if ( + !targetMod + || targetMod.enabled + || dependency.version && !dependency.version.testVersion(targetMod.version) + ) { + continue; + } + + targetMod.advisories.push({ + type: "recommended_dependency", + sourceModName: sourceMod.name, + }); + } + } + + for (const mod of annotatedMods) { + const availableVersion = availableUpdates.get(mod.name); + if (availableVersion && integerSourceVersion(availableVersion) > integerSourceVersion(mod.version)) { + mod.advisories.push({ type: "update_available", version: availableVersion }); + } + if (mod.advisories.length === 0) { + delete (mod as ModRecord).advisories; + } + } + + return annotatedMods; } const ModRecordJsonSchema = Type.Object({ diff --git a/packages/lib/src/data/index.ts b/packages/lib/src/data/index.ts index d93dc7d2..a8ca8a96 100644 --- a/packages/lib/src/data/index.ts +++ b/packages/lib/src/data/index.ts @@ -5,8 +5,8 @@ */ export { default as ExportManifest } from "./ExportManifest"; export { default as ModInfo, ModDependency } from "./ModInfo"; -export { ModSettingColor } from "./ModPack"; -export type { ModSetting, ModRecord } from "./ModPack"; +export { ModSettingColor, getInstalledModUpdates, applyModRecordAdvisories } from "./ModPack"; +export type { ModSetting, ModRecord, ModRecordAdvisory } from "./ModPack"; export { default as ModPack } from "./ModPack"; export { default as ModuleInfo } from "./ModuleInfo"; export { default as Permission } from "./Permission"; diff --git a/packages/web_ui/src/components/ModPackViewPage.tsx b/packages/web_ui/src/components/ModPackViewPage.tsx index 937450e2..c00a4315 100644 --- a/packages/web_ui/src/components/ModPackViewPage.tsx +++ b/packages/web_ui/src/components/ModPackViewPage.tsx @@ -11,6 +11,7 @@ import { 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"; @@ -545,7 +546,7 @@ function ModsTable(props: ModsTableProps) { } } - const mods = [...props.modPack.mods.values(), ...deletedMods.values()].map( + let mods = [...props.modPack.mods.values(), ...deletedMods.values()].map( (mod: lib.ModRecord): lib.ModRecord => { if (props.builtInModNames.includes(mod.name)) { return { @@ -590,6 +591,7 @@ function ModsTable(props: ModsTableProps) { } } } + mods = lib.applyModRecordAdvisories(mods, lib.getInstalledModUpdates(mods, modInfos.values())); async function fixDependencyIssues(mod: lib.ModRecord) { if (!mod.info) { @@ -766,27 +768,48 @@ function ModsTable(props: ModsTableProps) { { title: "Name", key: "name", - render: (_, mod: lib.ModRecord) => <> - {mod.error === "missing" && - {" "} - } - {mod.error === "bad_checksum" && - {" "} - } - {mod.warning === "incompatible" && - {" "} - } - {mod.warning === "missing_dependency" && - {" "} - } - {mod.warning === "wrong_version" && - {" "} - } - {mod.warning === "wrong_factorio_version" && - {" "} - } - {mod.info?.title || mod.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), }, diff --git a/test/lib/data/ModPack.js b/test/lib/data/ModPack.js index 5000845b..b813374b 100644 --- a/test/lib/data/ModPack.js +++ b/test/lib/data/ModPack.js @@ -3,7 +3,7 @@ const assert = require("assert").strict; const zlib = require("zlib"); const lib = require("@clusterio/lib"); -const { ModPack } = lib; +const { ModInfo, ModPack, getInstalledModUpdates, applyModRecordAdvisories } = lib; describe("lib/data/ModPack", function() { @@ -421,4 +421,84 @@ describe("lib/data/ModPack", function() { }); }); }); + + describe("applyModRecordAdvisories()", function() { + it("finds newer installed versions for the same Factorio version", function() { + const current = ModInfo.fromJSON({ + name: "source", + version: "1.0.0", + factorio_version: "2.0", + }); + const newer = ModInfo.fromJSON({ + name: "source", + version: "1.1.0", + factorio_version: "2.0", + }); + const otherFactorioVersion = ModInfo.fromJSON({ + name: "source", + version: "2.0.0", + factorio_version: "1.1", + }); + + const updates = getInstalledModUpdates([ + { name: "source", enabled: true, version: "1.0.0", info: current }, + ], [current, newer, otherFactorioVersion]); + + assert.deepEqual([...updates], [["source", "1.1.0"]]); + }); + + it("marks compatible disabled recommended dependencies", function() { + const sourceInfo = ModInfo.fromJSON({ + name: "source", + version: "1.0.0", + dependencies: ["+ quality >= 2.0.0", "+ stale >= 2.0.0", "+ active"], + }); + const otherInfo = ModInfo.fromJSON({ + name: "other", + version: "1.0.0", + dependencies: ["+ quality"], + }); + const mods = [ + { name: "source", enabled: true, version: "1.0.0", info: sourceInfo }, + { name: "other", enabled: true, version: "1.0.0", info: otherInfo }, + { name: "ignored", enabled: false, version: "1.0.0", info: otherInfo }, + { name: "quality", enabled: false, version: "2.0.0" }, + { name: "stale", enabled: false, version: "1.0.0" }, + { name: "active", enabled: true, version: "1.0.0" }, + ]; + + const annotated = applyModRecordAdvisories(mods); + + assert.deepEqual(annotated[3].advisories, [ + { type: "recommended_dependency", sourceModName: "source" }, + { type: "recommended_dependency", sourceModName: "other" }, + ]); + assert.equal(annotated[4].advisories, undefined); + assert.equal(annotated[5].advisories, undefined); + assert.equal(mods[3].advisories, undefined); + }); + + it("marks only newer resolved updates and preserves other advisories", function() { + const sourceInfo = ModInfo.fromJSON({ + name: "source", + version: "1.0.0", + dependencies: ["+ quality"], + }); + const mods = [ + { name: "source", enabled: true, version: "1.0.0", info: sourceInfo }, + { name: "quality", enabled: false, version: "2.0.0" }, + ]; + + const annotated = applyModRecordAdvisories(mods, new Map([ + ["source", "1.0.0"], + ["quality", "2.1.0"], + ])); + + assert.deepEqual(annotated[0].advisories, undefined); + assert.deepEqual(annotated[1].advisories, [ + { type: "recommended_dependency", sourceModName: "source" }, + { type: "update_available", version: "2.1.0" }, + ]); + }); + }); });