mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 07:40:44 +00:00
Fix Control UI i18n tooltip placeholders
Summary:
- Render the Sessions active filter tooltip with the configured minute count instead of a literal N.
- Update all Control UI locale bundles and i18n translation memory rows to preserve the {count} placeholder.
- Add a placeholder parity guard to the Control UI i18n check with regression coverage.
Verification:
- pnpm ui:i18n:check
- pnpm test src/scripts/control-ui-i18n.test.ts ui/src/ui/views/sessions.test.ts
- git diff --check
- Testbox exact-head pnpm check:changed passed on prior rebased head 1333aac90b before latest main churn.
- GitHub CI on fd2068c378 only failed the pre-existing unrelated checks-node-core-fast timeout in src/auto-reply/reply/followup-delivery.test.ts:176, also present on recent main runs b31c001a2b and e5f5989aa9.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
||||
@@ -13,6 +13,8 @@ interface TranslationMap {
|
||||
[key: string]: string | TranslationMap;
|
||||
}
|
||||
|
||||
type TranslationValue = string | { [key: string]: TranslationValue };
|
||||
|
||||
type LocaleEntry = {
|
||||
exportName: string;
|
||||
fileName: string;
|
||||
@@ -357,6 +359,68 @@ function compareStringArrays(left: string[], right: string[]) {
|
||||
return left.every((value, index) => value === right[index]);
|
||||
}
|
||||
|
||||
export type PlaceholderMismatch = {
|
||||
key: string;
|
||||
locale: string;
|
||||
sourcePlaceholders: string[];
|
||||
translatedPlaceholders: string[];
|
||||
};
|
||||
|
||||
function extractTranslationPlaceholders(text: string): string[] {
|
||||
return [...new Set([...text.matchAll(/\{(\w+)\}/g)].map((match) => match[1] ?? ""))]
|
||||
.filter(Boolean)
|
||||
.toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
export function findPlaceholderMismatches(
|
||||
sourceFlat: ReadonlyMap<string, string>,
|
||||
translatedFlat: ReadonlyMap<string, string>,
|
||||
locale: string,
|
||||
): PlaceholderMismatch[] {
|
||||
const mismatches: PlaceholderMismatch[] = [];
|
||||
for (const [key, sourceText] of sourceFlat.entries()) {
|
||||
const sourcePlaceholders = extractTranslationPlaceholders(sourceText);
|
||||
const translatedPlaceholders = extractTranslationPlaceholders(translatedFlat.get(key) ?? "");
|
||||
if (!compareStringArrays(sourcePlaceholders, translatedPlaceholders)) {
|
||||
mismatches.push({
|
||||
key,
|
||||
locale,
|
||||
sourcePlaceholders,
|
||||
translatedPlaceholders,
|
||||
});
|
||||
}
|
||||
}
|
||||
return mismatches;
|
||||
}
|
||||
|
||||
function assertPlaceholderParity(
|
||||
sourceFlat: ReadonlyMap<string, string>,
|
||||
translatedFlat: ReadonlyMap<string, string>,
|
||||
locale: string,
|
||||
) {
|
||||
const mismatches = findPlaceholderMismatches(sourceFlat, translatedFlat, locale);
|
||||
if (mismatches.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const details = mismatches
|
||||
.slice(0, 20)
|
||||
.map(
|
||||
(mismatch) =>
|
||||
`${mismatch.locale}:${mismatch.key} expected {${mismatch.sourcePlaceholders.join("},{")}} got {${mismatch.translatedPlaceholders.join("},{")}}`,
|
||||
)
|
||||
.join("\n");
|
||||
throw new Error(
|
||||
[
|
||||
`control-ui-i18n placeholder mismatch detected for ${locale}.`,
|
||||
details,
|
||||
mismatches.length > 20 ? `...and ${mismatches.length - 20} more` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
function isIdentifier(value: string): boolean {
|
||||
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(value);
|
||||
}
|
||||
@@ -1048,12 +1112,12 @@ class PiRpcClient {
|
||||
private readonly stderrChunks: string[] = [];
|
||||
private closed = false;
|
||||
private pending: PendingPrompt | null = null;
|
||||
private readonly process;
|
||||
private readonly stdin;
|
||||
private readonly process: ChildProcessWithoutNullStreams;
|
||||
private readonly stdin: ChildProcessWithoutNullStreams["stdin"];
|
||||
private requestCount = 0;
|
||||
private sequence = Promise.resolve();
|
||||
private sequence: Promise<unknown> = Promise.resolve();
|
||||
|
||||
private constructor(processHandle: ReturnType<typeof spawn>) {
|
||||
private constructor(processHandle: ChildProcessWithoutNullStreams) {
|
||||
this.process = processHandle;
|
||||
this.stdin = processHandle.stdin;
|
||||
}
|
||||
@@ -1174,7 +1238,7 @@ class PiRpcClient {
|
||||
}
|
||||
|
||||
async prompt(message: string, label: string): Promise<string> {
|
||||
this.sequence = this.sequence.then(async () => {
|
||||
const result = this.sequence.then(async () => {
|
||||
if (this.closed) {
|
||||
throw new Error(`pi process unavailable${this.stderr() ? ` (${this.stderr()})` : ""}`);
|
||||
}
|
||||
@@ -1236,7 +1300,8 @@ class PiRpcClient {
|
||||
});
|
||||
});
|
||||
|
||||
return (await this.sequence) as string;
|
||||
this.sequence = result.catch(() => undefined);
|
||||
return await result;
|
||||
}
|
||||
|
||||
async close() {
|
||||
@@ -1507,6 +1572,8 @@ async function syncLocale(
|
||||
// legitimately stay identical to English. Track fallback keys from actual
|
||||
// fallback decisions and previous fallback metadata instead.
|
||||
|
||||
assertPlaceholderParity(sourceFlat, nextFlat, entry.locale);
|
||||
|
||||
const nextMap: TranslationMap = {};
|
||||
for (const [key, value] of sourceFlat.entries()) {
|
||||
setNestedValue(nextMap, key, nextFlat.get(key) ?? value);
|
||||
@@ -1698,7 +1765,14 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
await main().catch((error) => {
|
||||
console.error(formatErrorMessage(error));
|
||||
process.exit(1);
|
||||
});
|
||||
function isCliEntrypoint() {
|
||||
const entrypoint = process.argv[1];
|
||||
return Boolean(entrypoint && import.meta.url === pathToFileURL(path.resolve(entrypoint)).href);
|
||||
}
|
||||
|
||||
if (isCliEntrypoint()) {
|
||||
await main().catch((error) => {
|
||||
console.error(formatErrorMessage(error));
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user