mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 15:04:52 +09:00
Show localised names of mod settings in the web UI (#1181)
The mod pack page only looked up mod-setting-name.<name>, so settings with a localised_name showed the internal name, and rich text tags in the locale showed up as raw text. Resolve localised_name and localised_description from the exported settings prototypes against the exported locale, falling back to the mod-setting-name/mod-setting-description keys and then the internal name. The resolver handles parameters, the "" and "?" keys and __ITEM__/__ENTITY__/__TILE__/__FLUID__ references. Rich text formatting tags are removed and prototype icons are replaced with their name. Fixes #768 Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c6867de13d
commit
6d9a32d28b
5 files changed
+290
-4
No files matched your search
@@ -23,6 +23,7 @@ import { useMods } from "../model/mods";
|
||||
import { useClipboard } from "../util/clipboard";
|
||||
import { modSettingFields, ModSettingField, ModSettingScope } from "../util/mod_settings";
|
||||
import notify, { notifyErrorHandler } from "../util/notify";
|
||||
import { modSettingDescription, modSettingName, modSettingValueName } from "../util/locale";
|
||||
import useResourceState from "../util/useResourceState";
|
||||
import PageHeader from "./PageHeader";
|
||||
import PageLayout from "./PageLayout";
|
||||
@@ -901,6 +902,8 @@ type SettingsTableFieldProps = {
|
||||
type: string;
|
||||
scope: ModSettingScope;
|
||||
allowedValues: any;
|
||||
localisedName?: lib.FactorioLocalisedString;
|
||||
localisedDescription?: lib.FactorioLocalisedString;
|
||||
locale: Map<string, any>;
|
||||
changed: boolean;
|
||||
/** Saved value of the setting, used when the field is created and when the form is reset */
|
||||
@@ -917,6 +920,7 @@ const SettingsTableField = memo((props: SettingsTableFieldProps) => {
|
||||
const isColor = props.type === "color-setting";
|
||||
const isNumber = ["int-setting", "double-setting"].includes(props.type);
|
||||
const isSelect = Boolean(props.allowedValues);
|
||||
const prototype = { name, localised_name: props.localisedName, localised_description: props.localisedDescription };
|
||||
let input;
|
||||
if (isBoolean) {
|
||||
input = <Checkbox />;
|
||||
@@ -924,22 +928,22 @@ const SettingsTableField = memo((props: SettingsTableFieldProps) => {
|
||||
const PartialInputColor = InputColor as () => React.JSX.Element;
|
||||
input = <PartialInputColor />;
|
||||
} else if (isSelect) {
|
||||
const options = Object.values(props.allowedValues).map(value => ({
|
||||
const options = Object.values(props.allowedValues as (string | number)[]).map(value => ({
|
||||
value,
|
||||
label: props.locale.get(`string-mod-setting.${name}-${value}`) || value,
|
||||
label: modSettingValueName(props.locale, name, value),
|
||||
}));
|
||||
input = <Select options={options} />;
|
||||
} else {
|
||||
input = <Input />;
|
||||
}
|
||||
|
||||
const label = props.locale.get(`mod-setting-name.${name}`) || name;
|
||||
const label = modSettingName(props.locale, prototype);
|
||||
const change: ModChange = {
|
||||
type: "settings.delete", scope: props.scope, name, value: { value: props.initialValue! },
|
||||
};
|
||||
return <Form.Item
|
||||
label={props.removed ? <Text delete>{label}</Text> : label}
|
||||
tooltip={props.locale.get(`mod-setting-description.${name}`)}
|
||||
tooltip={modSettingDescription(props.locale, prototype)}
|
||||
labelCol={{ span: 8 }}
|
||||
wrapperCol={{ span: 16 }}
|
||||
className={props.changed ? "changed" : ""}
|
||||
@@ -999,6 +1003,8 @@ function SettingsTable(props: SettingsTableProps) {
|
||||
type={setting.type}
|
||||
scope={scope}
|
||||
allowedValues={setting.allowed_values}
|
||||
localisedName={setting.localised_name}
|
||||
localisedDescription={setting.localised_description}
|
||||
locale={props.locale}
|
||||
changed={hasChange(props.changes, { type: "settings.set", scope, name: setting.name })}
|
||||
initialValue={props.savedModPack.settings[scope].get(setting.name)?.value}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import type { FactorioLocalisedString } from "@clusterio/lib";
|
||||
|
||||
type Locale = Map<string, string>;
|
||||
|
||||
const maxDepth = 20;
|
||||
const prototypeReferences: Record<string, string> = {
|
||||
ENTITY: "entity-name",
|
||||
ITEM: "item-name",
|
||||
TILE: "tile-name",
|
||||
FLUID: "fluid-name",
|
||||
};
|
||||
|
||||
function substitute(locale: Locale, template: string, parameters: string[]) {
|
||||
return template.replace(/__([A-Z_]+)__([^_\s][^\s]*?)__|__(\d+)__/g, (match, kind, name, index) => {
|
||||
if (index !== undefined) {
|
||||
return parameters[Number(index) - 1] ?? match;
|
||||
}
|
||||
const category = prototypeReferences[kind];
|
||||
return category ? locale.get(`${category}.${name}`) ?? name : match;
|
||||
});
|
||||
}
|
||||
|
||||
function resolve(locale: Locale, value: unknown, depth: number): string | undefined {
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
if (!Array.isArray(value) || typeof value[0] !== "string" || depth > maxDepth) {
|
||||
return undefined;
|
||||
}
|
||||
const [key, ...rest] = value;
|
||||
if (key === "?") {
|
||||
for (const alternative of rest) {
|
||||
const result = resolve(locale, alternative, depth + 1);
|
||||
if (result !== undefined) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
const parameters: string[] = [];
|
||||
for (const parameter of rest) {
|
||||
const result = resolve(locale, parameter, depth + 1);
|
||||
if (result === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
parameters.push(result);
|
||||
}
|
||||
if (key === "") {
|
||||
return parameters.join("");
|
||||
}
|
||||
const template = locale.get(key);
|
||||
return template === undefined ? undefined : substitute(locale, template, parameters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a Factorio LocalisedString against exported locale.
|
||||
*
|
||||
* @param locale - Flattened locale from the mod pack export.
|
||||
* @param value - Localised string to resolve.
|
||||
* @returns the resolved text, or undefined if a key used is missing from the locale.
|
||||
* @see https://lua-api.factorio.com/stable/types/LocalisedString.html
|
||||
*/
|
||||
export function resolveLocalisedString(locale: Locale, value: FactorioLocalisedString) {
|
||||
return resolve(locale, value, 0);
|
||||
}
|
||||
|
||||
const iconTags: Record<string, string> = {
|
||||
"item": "item-name",
|
||||
"entity": "entity-name",
|
||||
"fluid": "fluid-name",
|
||||
"tile": "tile-name",
|
||||
"recipe": "recipe-name",
|
||||
"technology": "technology-name",
|
||||
"item-group": "item-group-name",
|
||||
"virtual-signal": "virtual-signal-name",
|
||||
"space-location": "space-location-name",
|
||||
"planet": "space-location-name",
|
||||
"quality": "quality-name",
|
||||
};
|
||||
const otherTags = [
|
||||
"color", "font", "img", "achievement", "gps", "special-item", "armor", "train", "train-stop", "tooltip",
|
||||
"shortcut", "tip",
|
||||
];
|
||||
const richTextPattern = new RegExp(
|
||||
`\\[(?:/(?:color|font|tooltip)|(${[...Object.keys(iconTags), ...otherTags].join("|")})(?:=([^\\]]*))?)\\]`,
|
||||
"g",
|
||||
);
|
||||
|
||||
/**
|
||||
* Remove Factorio rich text tags from text, replacing icons of prototypes with their name.
|
||||
*
|
||||
* @param locale - Flattened locale from the mod pack export.
|
||||
* @param text - Text that may contain rich text tags.
|
||||
* @returns plain text
|
||||
* @see https://wiki.factorio.com/Rich_text
|
||||
*/
|
||||
export function stripRichText(locale: Locale, text: string) {
|
||||
return text.replace(richTextPattern, (_match, tag: string | undefined, value: string | undefined) => {
|
||||
const category = tag ? iconTags[tag] : undefined;
|
||||
if (!category || !value) {
|
||||
return "";
|
||||
}
|
||||
const name = value.split(",")[0];
|
||||
return locale.get(`${category}.${name}`) ?? name;
|
||||
}).trim();
|
||||
}
|
||||
|
||||
function localisedText(locale: Locale, localised: FactorioLocalisedString | undefined, key: string) {
|
||||
let text: string | undefined;
|
||||
if (localised !== undefined) {
|
||||
text = resolveLocalisedString(locale, localised);
|
||||
}
|
||||
text ??= resolveLocalisedString(locale, [key]);
|
||||
return text === undefined ? undefined : stripRichText(locale, text);
|
||||
}
|
||||
|
||||
type SettingPrototype = {
|
||||
name: string,
|
||||
localised_name?: FactorioLocalisedString,
|
||||
localised_description?: FactorioLocalisedString,
|
||||
};
|
||||
|
||||
/**
|
||||
* Display name of a mod setting, falling back the same way Factorio does.
|
||||
*
|
||||
* @param locale - Flattened locale from the mod pack export.
|
||||
* @param prototype - Mod setting prototype.
|
||||
* @returns localised name, or the internal name if there is none.
|
||||
*/
|
||||
export function modSettingName(locale: Locale, prototype: SettingPrototype) {
|
||||
return localisedText(locale, prototype.localised_name, `mod-setting-name.${prototype.name}`) || prototype.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Description of a mod setting, falling back the same way Factorio does.
|
||||
*
|
||||
* @param locale - Flattened locale from the mod pack export.
|
||||
* @param prototype - Mod setting prototype.
|
||||
* @returns localised description, or undefined if there is none.
|
||||
*/
|
||||
export function modSettingDescription(locale: Locale, prototype: SettingPrototype) {
|
||||
return localisedText(
|
||||
locale, prototype.localised_description, `mod-setting-description.${prototype.name}`
|
||||
) || undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display name of an allowed value of a string mod setting.
|
||||
*
|
||||
* @param locale - Flattened locale from the mod pack export.
|
||||
* @param name - Internal name of the mod setting.
|
||||
* @param value - Allowed value.
|
||||
* @returns localised value, or the value itself if there is none.
|
||||
*/
|
||||
export function modSettingValueName(locale: Locale, name: string, value: string | number) {
|
||||
return localisedText(locale, undefined, `string-mod-setting.${name}-${value}`) || String(value);
|
||||
}
|
||||
@@ -8,6 +8,8 @@ export type ModSettingField = {
|
||||
mod?: string;
|
||||
order?: string;
|
||||
allowed_values?: unknown;
|
||||
localised_name?: lib.FactorioLocalisedString;
|
||||
localised_description?: lib.FactorioLocalisedString;
|
||||
};
|
||||
|
||||
const settingTypes = ["bool-setting", "int-setting", "double-setting", "string-setting", "color-setting"];
|
||||
@@ -50,6 +52,8 @@ export function modSettingFields(
|
||||
mod: p.mod as string,
|
||||
order: p.order,
|
||||
allowed_values: p.allowed_values,
|
||||
localised_name: p.localised_name,
|
||||
localised_description: p.localised_description,
|
||||
}))
|
||||
.sort((a, b) => strcmp(a.mod, b.mod) || strcmp(a.order || "", b.order || "") || strcmp(a.name, b.name))
|
||||
;
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
modSettingDescription, modSettingName, modSettingValueName, resolveLocalisedString, stripRichText,
|
||||
} from "../../packages/web_ui/src/util/locale.ts";
|
||||
|
||||
describe("web_ui/util/locale", function() {
|
||||
const locale = new Map([
|
||||
["phi-cl.combine", "__1__ __2__"],
|
||||
["mod-setting-name-prefix.PHI-MB", "[color=255,254,42]MB[/color]"],
|
||||
["mod-setting-name.PHI-MB", "[color=255,254,42]Enable better entity[/color]"],
|
||||
["mod-setting-name.plain", "Plain setting"],
|
||||
["mod-setting-description.plain", "Plain description"],
|
||||
["string-mod-setting.mode-fast", "[font=default-bold]Fast[/font]"],
|
||||
["item-name.iron-plate", "Iron plate"],
|
||||
["entity-name.inserter", "Inserter"],
|
||||
["uses", "Uses __ITEM__iron-plate__ and __ENTITY__inserter__"],
|
||||
["greeting", "Hello __1__"],
|
||||
]);
|
||||
|
||||
describe("resolveLocalisedString()", function() {
|
||||
it("should return plain strings and numbers as is", function() {
|
||||
assert.equal(resolveLocalisedString(locale, "text"), "text");
|
||||
assert.equal(resolveLocalisedString(locale, 5), "5");
|
||||
});
|
||||
it("should substitute parameters", function() {
|
||||
assert.equal(resolveLocalisedString(locale, ["greeting", "world"]), "Hello world");
|
||||
assert.equal(resolveLocalisedString(locale, ["greeting", ["greeting", 2]]), "Hello Hello 2");
|
||||
});
|
||||
it("should leave parameters that were not given", function() {
|
||||
assert.equal(resolveLocalisedString(locale, ["greeting"]), "Hello __1__");
|
||||
});
|
||||
it("should substitute prototype names", function() {
|
||||
assert.equal(resolveLocalisedString(locale, ["uses"]), "Uses Iron plate and Inserter");
|
||||
});
|
||||
it("should concatenate with an empty key", function() {
|
||||
assert.equal(resolveLocalisedString(locale, ["", "a", ["greeting", "b"], 3]), "aHello b3");
|
||||
});
|
||||
it("should use the first alternative that resolves with a ? key", function() {
|
||||
assert.equal(resolveLocalisedString(locale, ["?", ["missing"], ["greeting", "x"], "y"]), "Hello x");
|
||||
assert.equal(resolveLocalisedString(locale, ["?", ["missing"]]), undefined);
|
||||
});
|
||||
it("should return undefined if a key is missing", function() {
|
||||
assert.equal(resolveLocalisedString(locale, ["missing"]), undefined);
|
||||
assert.equal(resolveLocalisedString(locale, ["greeting", ["missing"]]), undefined);
|
||||
});
|
||||
it("should return undefined for nesting that is too deep", function() {
|
||||
let value = "end";
|
||||
for (let i = 0; i < 30; i++) {
|
||||
value = ["", value];
|
||||
}
|
||||
assert.equal(resolveLocalisedString(locale, value), undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripRichText()", function() {
|
||||
it("should remove formatting tags", function() {
|
||||
assert.equal(stripRichText(locale, "[color=red]A[/color] [font=default-bold]B[/font]"), "A B");
|
||||
});
|
||||
it("should replace prototype icons with their name", function() {
|
||||
assert.equal(stripRichText(locale, "[item=iron-plate] plates"), "Iron plate plates");
|
||||
assert.equal(stripRichText(locale, "[item=iron-plate,quality=rare]"), "Iron plate");
|
||||
assert.equal(stripRichText(locale, "[fluid=unknown]"), "unknown");
|
||||
});
|
||||
it("should remove other icons", function() {
|
||||
assert.equal(stripRichText(locale, "[img=utility/warning_icon] Warning"), "Warning");
|
||||
});
|
||||
it("should keep brackets that are not tags", function() {
|
||||
assert.equal(stripRichText(locale, "[Beta] [x=1]"), "[Beta] [x=1]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("modSettingName()", function() {
|
||||
it("should resolve localised_name", function() {
|
||||
const prototype = {
|
||||
name: "PHI-MB",
|
||||
localised_name: ["phi-cl.combine", ["mod-setting-name-prefix.PHI-MB"], ["mod-setting-name.PHI-MB"]],
|
||||
};
|
||||
assert.equal(modSettingName(locale, prototype), "MB Enable better entity");
|
||||
});
|
||||
it("should fall back to the mod-setting-name key", function() {
|
||||
assert.equal(modSettingName(locale, { name: "plain" }), "Plain setting");
|
||||
assert.equal(modSettingName(locale, { name: "plain", localised_name: ["missing"] }), "Plain setting");
|
||||
});
|
||||
it("should fall back to the internal name", function() {
|
||||
assert.equal(modSettingName(locale, { name: "other" }), "other");
|
||||
assert.equal(modSettingName(new Map(), { name: "plain" }), "plain");
|
||||
});
|
||||
});
|
||||
|
||||
describe("modSettingDescription()", function() {
|
||||
it("should resolve localised_description", function() {
|
||||
const prototype = { name: "plain", localised_description: ["greeting", "desc"] };
|
||||
assert.equal(modSettingDescription(locale, prototype), "Hello desc");
|
||||
});
|
||||
it("should fall back to the mod-setting-description key", function() {
|
||||
assert.equal(modSettingDescription(locale, { name: "plain" }), "Plain description");
|
||||
});
|
||||
it("should return undefined without a description", function() {
|
||||
assert.equal(modSettingDescription(locale, { name: "other" }), undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe("modSettingValueName()", function() {
|
||||
it("should resolve the string-mod-setting key", function() {
|
||||
assert.equal(modSettingValueName(locale, "mode", "fast"), "Fast");
|
||||
});
|
||||
it("should fall back to the value", function() {
|
||||
assert.equal(modSettingValueName(locale, "mode", "slow"), "slow");
|
||||
assert.equal(modSettingValueName(locale, "mode", 3), "3");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,7 @@ describe("web_ui/util/mod_settings", function() {
|
||||
"first": {
|
||||
type: "string-setting", name: "first", mod: "a", setting_type: "startup",
|
||||
default_value: "x", allowed_values: ["x", "y"],
|
||||
localised_name: ["name.first"], localised_description: ["description.first"],
|
||||
},
|
||||
},
|
||||
"unrelated": { "gone-number": { type: "unrelated", name: "gone-number", setting_type: "startup" } },
|
||||
@@ -30,6 +31,8 @@ describe("web_ui/util/mod_settings", function() {
|
||||
const { known, unknown } = modSettingFields(prototypes, "startup", stored);
|
||||
assert.deepEqual(known.map(field => field.name), ["first", "kept"]);
|
||||
assert.deepEqual(known[0].allowed_values, ["x", "y"]);
|
||||
assert.deepEqual(known[0].localised_name, ["name.first"]);
|
||||
assert.deepEqual(known[0].localised_description, ["description.first"]);
|
||||
assert.deepEqual(
|
||||
unknown.map(field => [field.name, field.type]),
|
||||
[["gone-color", "color-setting"], ["gone-number", "double-setting"], ["gone-string", "string-setting"]],
|
||||
|
||||
Reference in new issue
Block a user