* feat: AI translations at release, 7 new languages and regional fallback

* fix: proper plural forms for count strings and full translations

* fix: plural forms for selected, hidden and active counts

* fix: synced plugin updates failing on desktop and server-only plugins shown as stopped

* fix: restore rounded corners on app icons and favicon

* feat: desktop app auto updates with automatic, ask first or never modes

* feat: community and custom plugin registries

* feat: plugin guide for AI tools in the SDK with a lint check

* fix: keep the terminal WebSocket open past nginx's 60s default (#1588)

* chore: update changelog

* feat: show plugin download counts only and stop CI builds inflating them

* feat: hide installed plugins in the browse tab by default

* chore: pin bundled plugins to latest releases

* chore: update translations

* fix: focusing a saved password field and saving no longer erases it

* fix: tell desktop users when their 2.8 data needs 2.9 first

* fix: point windows update info at the x64 installer

* fix: keep desktop client tunnel sockets alive behind proxies

* chore: pin bundled plugins to latest releases

* fix: cover client tunnel keepalive in the c2s runtime tests

* fix: typing after opening a host no longer goes to the host search

* fix: accept tab and = separators in SSH config import (#1598)

* chore: update changelog

* chore: drop plugin versions from core changelog

* chore: update translations

* chore: lint, format, and bump version to 26.10.1

---------

Co-authored-by: Darnel Hunter <68697450+innotelinc@users.noreply.github.com>
Co-authored-by: Iain <Iain1970@protonmail.com>
This commit is contained in:
authored and GitHub committed 2026-10-12 11:35:48 -05:00
1 parent 6873bc92bb
commit 62ac931468
174 files changed
+94334 -39995

No files matched your search

+117
View File
@@ -0,0 +1,117 @@
#!/usr/bin/env node
/**
* packages/plugin-sdk/PLUGIN-GUIDE.md ships with the SDK and is what plugin
* authors and their AI tools read first. Fails when it misses a CLI command,
* a capability or an entry point, or when its header names an older SDK or
* plugin API version, so the guide is touched with every SDK release.
*/
const fs = require("node:fs");
const path = require("node:path");
const ROOT = path.resolve(__dirname, "..");
const SDK = path.join(ROOT, "packages", "plugin-sdk");
// Exports that are plumbing, not something a plugin author imports.
const INTERNAL_EXPORTS = new Set([
"./testing/setup",
"./testing-host",
"./tsconfig.plugin.json",
"./schema/manifest.schema.json",
"./theme.css",
]);
function cliCommands(source) {
const block = source.match(/const COMMANDS = \{([\s\S]*?)\n\};/);
if (!block) return [];
return [...block[1].matchAll(/^ {2}([a-z-]+)[:,]/gm)].map((m) => m[1]);
}
function capabilityIds(source) {
return [
...new Set(
[...source.matchAll(/entry\("([a-z-]+:[a-z-]+)"/g)].map((m) => m[1]),
),
];
}
function entryPoints(pkg) {
return Object.keys(pkg.exports ?? {})
.filter((key) => key !== "." && !INTERNAL_EXPORTS.has(key))
.map((key) => `@termix-ssh/plugin-sdk/${key.slice(2)}`);
}
function apiVersion(source) {
return source.match(/PLUGIN_API_VERSION = "([^"]+)"/)?.[1] ?? null;
}
function findProblems({
guide,
commands,
capabilities,
entries,
sdkVersion,
api,
}) {
const problems = [];
for (const command of commands) {
// `sign <file>` counts as well as `sign`.
if (!new RegExp(`\`${command}[\` ]`).test(guide))
problems.push(`CLI command "${command}" is not covered`);
}
for (const id of capabilities) {
if (!guide.includes(`\`${id}\``))
problems.push(`capability "${id}" is not covered`);
}
for (const entry of entries) {
if (!guide.includes(`\`${entry}\``))
problems.push(`entry point "${entry}" is not covered`);
}
const header = guide.match(/^SDK (\S+) \| Plugin API (\S+)$/m);
if (!header) {
problems.push('missing the "SDK x.y.z | Plugin API x.y.z" header line');
} else {
if (header[1] !== sdkVersion) {
problems.push(`header says SDK ${header[1]}, package is ${sdkVersion}`);
}
if (api && header[2] !== api) {
problems.push(`header says Plugin API ${header[2]}, SDK has ${api}`);
}
}
return problems;
}
function main() {
const read = (file) => fs.readFileSync(path.join(SDK, file), "utf8");
const pkg = JSON.parse(read("package.json"));
const problems = findProblems({
guide: read("PLUGIN-GUIDE.md"),
commands: cliCommands(read("cli/index.mjs")),
capabilities: capabilityIds(read("src/capabilities.ts")),
entries: entryPoints(pkg),
sdkVersion: pkg.version,
api: apiVersion(read("src/manifest.ts")),
});
if (!pkg.files?.includes("PLUGIN-GUIDE.md")) {
problems.push(
'"PLUGIN-GUIDE.md" is not in the SDK package.json files list',
);
}
if (problems.length > 0) {
console.error(
"check-sdk-guide: update packages/plugin-sdk/PLUGIN-GUIDE.md",
);
for (const problem of problems) console.error(` ${problem}`);
process.exit(1);
}
}
if (require.main === module) main();
module.exports = {
cliCommands,
capabilityIds,
entryPoints,
apiVersion,
findProblems,
};
+113
View File
@@ -0,0 +1,113 @@
import { describe, expect, it } from "vitest";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const { cliCommands, capabilityIds, entryPoints, apiVersion, findProblems } =
require("./check-sdk-guide.cjs") as {
cliCommands: (source: string) => string[];
capabilityIds: (source: string) => string[];
entryPoints: (pkg: { exports?: Record<string, unknown> }) => string[];
apiVersion: (source: string) => string | null;
findProblems: (input: {
guide: string;
commands: string[];
capabilities: string[];
entries: string[];
sdkVersion: string;
api: string | null;
}) => string[];
};
const GUIDE = [
"# Guide",
"",
"SDK 1.2.3 | Plugin API 1.4.0",
"",
"| `build` | builds |",
"| `sign <file>` | signs |",
"| `db:own` | tables |",
"| `@termix-ssh/plugin-sdk/db` | tables |",
].join("\n");
const input = {
guide: GUIDE,
commands: ["build", "sign"],
capabilities: ["db:own"],
entries: ["@termix-ssh/plugin-sdk/db"],
sdkVersion: "1.2.3",
api: "1.4.0",
};
describe("readers", () => {
it("reads command names from the COMMANDS map", () => {
const source = [
"const COMMANDS = {",
" build,",
" patch: async () => {},",
" test: async (options) => {",
" return run(options);",
" },",
" verify,",
"};",
].join("\n");
expect(cliCommands(source)).toEqual(["build", "patch", "test", "verify"]);
});
it("reads capability ids once each", () => {
const source =
'entry("db:own", "low"),\nentry("ssh:connect", "high"),\nentry("db:own", "low")';
expect(capabilityIds(source)).toEqual(["db:own", "ssh:connect"]);
});
it("skips the root and internal exports", () => {
expect(
entryPoints({
exports: {
".": {},
"./db": {},
"./testing/setup": {},
"./theme.css": "",
},
}),
).toEqual(["@termix-ssh/plugin-sdk/db"]);
});
it("reads the plugin API version", () => {
expect(apiVersion('export const PLUGIN_API_VERSION = "1.2.0";')).toBe(
"1.2.0",
);
});
});
describe("findProblems", () => {
it("passes a guide that covers everything", () => {
expect(findProblems(input)).toEqual([]);
});
it("names what is missing", () => {
expect(
findProblems({
...input,
commands: ["build", "dev"],
capabilities: ["db:own", "kv:own"],
entries: ["@termix-ssh/plugin-sdk/ui"],
}),
).toEqual([
'CLI command "dev" is not covered',
'capability "kv:own" is not covered',
'entry point "@termix-ssh/plugin-sdk/ui" is not covered',
]);
});
it("fails when the header is behind the SDK", () => {
expect(
findProblems({ ...input, sdkVersion: "1.2.4", api: "1.5.0" }),
).toEqual([
"header says SDK 1.2.3, package is 1.2.4",
"header says Plugin API 1.4.0, SDK has 1.5.0",
]);
expect(findProblems({ ...input, guide: "no header" })).toContain(
'missing the "SDK x.y.z | Plugin API x.y.z" header line',
);
});
});
+372
View File
@@ -0,0 +1,372 @@
import { createRequire } from "node:module";
import path from "node:path";
import { EventEmitter } from "node:events";
import { afterEach, describe, expect, it, vi } from "vitest";
const require = createRequire(import.meta.url);
const updates = require("../electron/auto-updater.cjs") as {
channelFile: (channel: string, platform: string, arch: string) => string;
getInstallKind: (input: Record<string, unknown>) => string;
resolveRelease: (
releases: unknown,
opts: { currentVersion: string; platform: string; arch: string },
) => {
tag: string;
version: string;
channel: string;
feedUrl: string;
releaseUrl: string;
} | null;
createUpdater: (deps: Record<string, unknown>) => {
getState: () => Record<string, unknown>;
check: () => Promise<Record<string, unknown>>;
download: () => Promise<Record<string, unknown>>;
install: () => boolean;
setMode: (mode: string) => Record<string, unknown>;
};
};
function release(tag: string, assets: string[], extra = {}) {
return {
tag_name: tag,
draft: false,
html_url: `https://github.com/Termix-SSH/Termix/releases/tag/${tag}`,
assets: assets.map((name) => ({ name })),
...extra,
};
}
const ALL = [
"latest.yml",
"latest-mac.yml",
"latest-linux.yml",
"latest-linux-arm64.yml",
"latest-linux-arm.yml",
"beta.yml",
"beta-mac.yml",
"beta-linux.yml",
];
const win = { platform: "win32", arch: "x64" };
describe("channelFile", () => {
it("matches the names electron-builder writes", () => {
expect(updates.channelFile("latest", "win32", "x64")).toBe("latest.yml");
expect(updates.channelFile("latest", "darwin", "arm64")).toBe(
"latest-mac.yml",
);
expect(updates.channelFile("latest", "linux", "x64")).toBe(
"latest-linux.yml",
);
expect(updates.channelFile("beta", "linux", "arm64")).toBe(
"beta-linux-arm64.yml",
);
expect(updates.channelFile("latest", "linux", "arm")).toBe(
"latest-linux-arm.yml",
);
});
});
describe("resolveRelease", () => {
it("picks the newest stable release and points the feed at its tag", () => {
const found = updates.resolveRelease(
[release("v26.10.2", ALL), release("v26.10.3", ALL)],
{ currentVersion: "26.10.1", ...win },
);
expect(found).toMatchObject({
tag: "v26.10.3",
version: "26.10.3",
channel: "latest",
feedUrl:
"https://github.com/Termix-SSH/Termix/releases/download/v26.10.3",
});
});
it("ignores SDK, 2.9.x patch and draft releases", () => {
expect(
updates.resolveRelease(
[
release("sdk-v1.0.9", ALL),
release("release-2.9.4-tag", ALL),
release("v26.11.0", ALL, { draft: true }),
],
{ currentVersion: "26.10.1", ...win },
),
).toBeNull();
});
it("returns null when nothing is newer", () => {
expect(
updates.resolveRelease([release("v26.10.1", ALL)], {
currentVersion: "26.10.1",
...win,
}),
).toBeNull();
});
it("keeps stable installs off betas", () => {
expect(
updates.resolveRelease([release("v26.11.0-beta.1", ALL)], {
currentVersion: "26.10.1",
...win,
}),
).toBeNull();
});
it("moves a beta install to the next beta, then to stable", () => {
expect(
updates.resolveRelease(
[release("v26.11.0-beta.1", ALL), release("v26.11.0-beta.2", ALL)],
{ currentVersion: "26.11.0-beta.1", ...win },
),
).toMatchObject({ tag: "v26.11.0-beta.2", channel: "beta" });
expect(
updates.resolveRelease(
[release("v26.11.0-beta.2", ALL), release("v26.11.0", ALL)],
{ currentVersion: "26.11.0-beta.2", ...win },
),
).toMatchObject({ tag: "v26.11.0", channel: "latest" });
});
it("waits while the newest release is still uploading", () => {
expect(
updates.resolveRelease(
[release("v26.10.2", ALL), release("v26.10.3", ["termix.exe"])],
{ currentVersion: "26.10.1", ...win },
),
).toBeNull();
});
it("needs this platform's channel file", () => {
const releases = [release("v26.10.2", ["latest.yml", "latest-mac.yml"])];
expect(
updates.resolveRelease(releases, {
currentVersion: "26.10.1",
platform: "linux",
arch: "x64",
}),
).toBeNull();
expect(
updates.resolveRelease(releases, {
currentVersion: "26.10.1",
platform: "darwin",
arch: "arm64",
}),
).not.toBeNull();
});
it("handles junk input", () => {
expect(
updates.resolveRelease(null, { currentVersion: "26.10.1", ...win }),
).toBeNull();
expect(
updates.resolveRelease([release("v26.10.2", ALL)], {
currentVersion: "dev",
...win,
}),
).toBeNull();
});
});
describe("getInstallKind", () => {
const base = {
platform: "win32",
arch: "x64",
isPackaged: true,
mas: false,
env: {},
execPath: path.join("C:", "Program Files", "Termix", "Termix.exe"),
resourcesPath: "/opt/Termix/resources",
exists: () => false,
readFile: () => {
throw new Error("missing");
},
};
it("disables dev builds and leaves the App Store alone", () => {
expect(updates.getInstallKind({ ...base, isPackaged: false })).toBe(
"disabled",
);
expect(
updates.getInstallKind({ ...base, platform: "darwin", mas: true }),
).toBe("store");
});
it("updates NSIS installs and only links MSI, portable and 32-bit", () => {
const uninstaller = path.join(
path.dirname(base.execPath),
"Uninstall Termix.exe",
);
const nsis = (target: string) => target === uninstaller;
expect(updates.getInstallKind({ ...base, exists: nsis })).toBe("auto");
expect(updates.getInstallKind(base)).toBe("manual");
expect(
updates.getInstallKind({ ...base, arch: "ia32", exists: nsis }),
).toBe("manual");
});
it("updates DMG installs unless run from the mounted image", () => {
const mac = { ...base, platform: "darwin", arch: "arm64" };
expect(
updates.getInstallKind({
...mac,
execPath: "/Applications/Termix.app/Contents/MacOS/Termix",
}),
).toBe("auto");
expect(
updates.getInstallKind({
...mac,
execPath: "/Volumes/Termix/Termix.app/Contents/MacOS/Termix",
}),
).toBe("manual");
});
it("tells Linux installs apart", () => {
const linux = { ...base, platform: "linux" };
expect(updates.getInstallKind({ ...linux, env: { FLATPAK_ID: "x" } })).toBe(
"manual",
);
expect(
updates.getInstallKind({
...linux,
exists: (target: string) => target === "/.flatpak-info",
}),
).toBe("manual");
expect(
updates.getInstallKind({ ...linux, env: { APPIMAGE: "/tmp/a" } }),
).toBe("auto");
const dpkgOwned = (target: string) =>
target === "/var/lib/dpkg/info/termix.list";
expect(
updates.getInstallKind({
...linux,
exists: dpkgOwned,
readFile: () => "deb\n",
}),
).toBe("auto");
// The AUR package ships the deb's files without dpkg.
expect(updates.getInstallKind({ ...linux, readFile: () => "deb\n" })).toBe(
"manual",
);
expect(updates.getInstallKind(linux)).toBe("manual");
});
});
describe("createUpdater", () => {
afterEach(() => vi.useRealTimers());
function fakeAutoUpdater() {
const emitter = new EventEmitter() as EventEmitter &
Record<string, unknown>;
emitter.setFeedURL = vi.fn();
emitter.checkForUpdates = vi.fn(async () => {
emitter.emit("update-available", { version: "26.10.2" });
});
emitter.downloadUpdate = vi.fn(async () => {
emitter.emit("update-downloaded", { version: "26.10.2" });
});
emitter.quitAndInstall = vi.fn();
return emitter;
}
function setup(kind = "auto", mode = "prompt") {
const auto = fakeAutoUpdater();
const saved: string[] = [];
const sent: Array<Record<string, unknown>> = [];
const beforeInstall = vi.fn();
const updater = updates.createUpdater({
currentVersion: "26.10.1",
...win,
kind,
getMode: () => mode,
saveMode: (next: string) => saved.push(next),
fetchReleases: async () => [release("v26.10.2", ALL)],
loadAutoUpdater: () => auto,
send: (state: Record<string, unknown>) => sent.push(state),
log: () => {},
beforeInstall,
});
return { auto, updater, saved, sent, beforeInstall };
}
it("asks before downloading in prompt mode", async () => {
const { auto, updater } = setup();
const state = await updater.check();
expect(auto.setFeedURL).toHaveBeenCalledWith({
provider: "generic",
url: "https://github.com/Termix-SSH/Termix/releases/download/v26.10.2",
});
expect(auto.autoDownload).toBe(false);
expect(auto.allowDowngrade).toBe(false);
expect(state).toMatchObject({ status: "available", version: "26.10.2" });
expect((await updater.download()).status).toBe("downloaded");
expect(updater.install()).toBe(true);
});
it("downloads on its own in automatic mode", async () => {
const { auto, updater } = setup("auto", "automatic");
await updater.check();
expect(auto.autoDownload).toBe(true);
expect(updater.getState().status).toBe("downloading");
});
it("marks the quit before installing so the tray lets go", async () => {
vi.useFakeTimers();
const { auto, updater, beforeInstall } = setup();
await updater.check();
await updater.download();
updater.install();
expect(beforeInstall).toHaveBeenCalled();
vi.runAllTimers();
expect(auto.quitAndInstall).toHaveBeenCalled();
});
it("only links the release for installs that can not update", async () => {
const { auto, updater } = setup("manual");
const state = await updater.check();
expect(auto.setFeedURL).not.toHaveBeenCalled();
expect(state).toMatchObject({
status: "available",
releaseUrl: "https://github.com/Termix-SSH/Termix/releases/tag/v26.10.2",
});
expect(updater.install()).toBe(false);
});
it("does nothing for store and dev builds", async () => {
for (const kind of ["store", "disabled"]) {
const { auto, updater } = setup(kind);
expect((await updater.check()).status).toBe("idle");
expect(auto.setFeedURL).not.toHaveBeenCalled();
}
});
it("saves valid modes only and falls back to prompt", () => {
const { updater, saved } = setup("auto", "bogus");
expect(updater.getState().mode).toBe("prompt");
updater.setMode("never");
updater.setMode("sometimes");
expect(saved).toEqual(["never"]);
expect(updater.getState().mode).toBe("never");
});
it("reports a failed lookup instead of throwing", async () => {
const updater = updates.createUpdater({
currentVersion: "26.10.1",
...win,
kind: "auto",
getMode: () => "prompt",
saveMode: () => {},
fetchReleases: async () => {
throw new Error("offline");
},
loadAutoUpdater: () => fakeAutoUpdater(),
send: () => {},
log: () => {},
});
expect(await updater.check()).toMatchObject({
status: "error",
error: "offline",
});
});
});
+11
View File
@@ -1,5 +1,6 @@
import { createRequire } from "node:module";
import { describe, expect, it } from "vitest";
import { PRE_29_DATABASE_ERROR } from "../src/backend/database/db/upgrade-guard.js";
const require = createRequire(import.meta.url);
const { classifyBackendFailure } =
@@ -46,6 +47,16 @@ describe("classifyBackendFailure", () => {
});
});
it("identifies a 2.8 database the backend refused to open", () => {
const stderr = `[ERROR] [SYSTEM] Failed to initialize backend services [op:startup_failed]
Error: ${PRE_29_DATABASE_ERROR}
at assertNotPre29Database (upgrade-guard.js:14:11)`;
expect(classifyBackendFailure(stderr)).toEqual({
reason: "old-database",
port: null,
});
});
it("reports a crash for any other non-zero exit", () => {
expect(classifyBackendFailure("TypeError: boom")).toEqual({
reason: "crashed",
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env node
/**
* Fixes electron-builder's update files (latest.yml and friends) after the
* build, so the desktop updater gets the right download:
*
* --drop <text> remove entries whose file name contains text
* (the ia32 installer, the combined Windows
* installer, the universal mac zip)
* --rename <old=new> follow an artifact the workflow renamed
*
* Usage: node scripts/fix-update-info.cjs [--drop x] [--rename a=b] files...
*/
const fs = require("node:fs");
const yaml = require("js-yaml");
function fixUpdateInfo(info, { drop = [], rename = {} } = {}) {
const rewrite = (name) => rename[name] ?? name;
const seen = new Set();
// electron-builder can list the same file more than once.
const files = (info.files ?? [])
.filter((file) => !drop.some((text) => file.url.includes(text)))
.map((file) => ({ ...file, url: rewrite(file.url) }))
.filter((file) => !seen.has(file.url) && seen.add(file.url));
if (files.length === 0) {
throw new Error("no files left in update info");
}
return { ...info, files, path: files[0].url, sha512: files[0].sha512 };
}
function parseArgs(argv) {
const options = { drop: [], rename: {}, files: [] };
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === "--drop") options.drop.push(argv[++i]);
else if (arg === "--rename") {
const [from, to] = String(argv[++i]).split("=");
if (!from || !to) throw new Error(`bad --rename ${argv[i]}`);
options.rename[from] = to;
} else options.files.push(arg);
}
return options;
}
function main() {
const options = parseArgs(process.argv.slice(2));
for (const file of options.files) {
if (!fs.existsSync(file)) continue;
const info = yaml.load(fs.readFileSync(file, "utf8"));
fs.writeFileSync(file, yaml.dump(fixUpdateInfo(info, options)));
console.log(`Fixed ${file}`);
}
}
if (require.main === module) {
try {
main();
} catch (error) {
console.error(error.message);
process.exit(1);
}
}
module.exports = { fixUpdateInfo, parseArgs };
+87
View File
@@ -0,0 +1,87 @@
import { createRequire } from "node:module";
import { describe, expect, it } from "vitest";
const require = createRequire(import.meta.url);
const { fixUpdateInfo, parseArgs } = require("./fix-update-info.cjs");
const info = {
version: "26.10.2",
files: [
{ url: "termix_windows_ia32_nsis.exe", sha512: "a", size: 1 },
{ url: "termix_windows_x64_nsis.exe", sha512: "b", size: 2 },
],
path: "termix_windows_ia32_nsis.exe",
sha512: "a",
releaseDate: "2026-10-11T00:00:00.000Z",
};
describe("fixUpdateInfo", () => {
it("drops entries and points path at what is left", () => {
const fixed = fixUpdateInfo(info, { drop: ["ia32"] });
expect(fixed.files.map((f: { url: string }) => f.url)).toEqual([
"termix_windows_x64_nsis.exe",
]);
expect(fixed.path).toBe("termix_windows_x64_nsis.exe");
expect(fixed.sha512).toBe("b");
expect(fixed.version).toBe("26.10.2");
});
it("drops the combined Windows installer and duplicate entries", () => {
const fixed = fixUpdateInfo(
{
...info,
files: [
{ url: "termix_windows_nsis.exe", sha512: "c", size: 3 },
{ url: "termix_windows_ia32_nsis.exe", sha512: "a", size: 1 },
{ url: "termix_windows_x64_nsis.exe", sha512: "b", size: 2 },
{ url: "termix_windows_x64_nsis.exe", sha512: "b", size: 2 },
],
path: "termix_windows_nsis.exe",
},
{ drop: ["ia32", "termix_windows_nsis.exe"] },
);
expect(fixed.files).toEqual([
{ url: "termix_windows_x64_nsis.exe", sha512: "b", size: 2 },
]);
expect(fixed.path).toBe("termix_windows_x64_nsis.exe");
});
it("follows renamed artifacts", () => {
const fixed = fixUpdateInfo(
{
...info,
files: [{ url: "termix_linux_x86_64_appimage.AppImage", sha512: "c" }],
},
{
rename: {
"termix_linux_x86_64_appimage.AppImage":
"termix_linux_x64_appimage.AppImage",
},
},
);
expect(fixed.path).toBe("termix_linux_x64_appimage.AppImage");
});
it("refuses to leave nothing to download", () => {
expect(() => fixUpdateInfo(info, { drop: ["nsis"] })).toThrow();
});
});
describe("parseArgs", () => {
it("reads drops, renames and files", () => {
expect(
parseArgs([
"--drop",
"ia32",
"--rename",
"a=b",
"latest.yml",
"beta.yml",
]),
).toEqual({
drop: ["ia32"],
rename: { a: "b" },
files: ["latest.yml", "beta.yml"],
});
});
});
+33 -6
View File
@@ -20,9 +20,30 @@ const OUT = path.join(
"settings-search-index.ts",
);
function flatKeys(obj, prefix = "") {
return Object.entries(obj).flatMap(([key, value]) =>
typeof value === "object"
? flatKeys(value, `${prefix}${key}.`)
: [`${prefix}${key}`],
);
}
const EN_KEYS = new Set(
flatKeys(require(path.join(ROOT, "src", "ui", "locales", "en.json"))),
);
// t("x", { count }) reads x_one or x_other, so index the plural key.
function realKey(key) {
return EN_KEYS.has(key) || !EN_KEYS.has(`${key}_other`)
? key
: `${key}_other`;
}
function keysIn(text) {
return [
...new Set([...text.matchAll(/\bt\(\s*"([\w.-]+)"/g)].map((m) => m[1])),
...new Set(
[...text.matchAll(/\bt\(\s*"([\w.-]+)"/g)].map((m) => realKey(m[1])),
),
];
}
@@ -110,12 +131,18 @@ function adminSections() {
"admin-ssl": keysIn(functionBody(settings, "AdminSSLSection")),
"admin-api-keys": keysIn(read("AdminApiKeysSection.tsx")),
"admin-audit-log": keysIn(read("AdminAuditLogSection.tsx")),
updates: keysIn(
fs.readFileSync(
path.join(ROOT, "src", "ui", "settings", "UpdatesSettings.tsx"),
"utf8",
updates: [
...new Set(
["UpdatesSettings.tsx", "DesktopUpdatesCard.tsx"].flatMap((file) =>
keysIn(
fs.readFileSync(
path.join(ROOT, "src", "ui", "settings", file),
"utf8",
),
),
),
),
),
],
};
}