mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-04-19 23:08:08 +02:00
Settings i18n improvements (#22571)
* i18n improvements for settings UI - deduplicate shared detector translation keys and centralize config translation resolution - add missing i18n keys * formatting
This commit is contained in:
@@ -75,7 +75,9 @@ export default function CameraReviewStatusToggles({
|
||||
/>
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="detections-enabled">
|
||||
<Trans ns="views/settings">camera.review.detections</Trans>
|
||||
<Trans ns="views/settings">
|
||||
cameraReview.review.detections
|
||||
</Trans>
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1136,7 +1136,7 @@ export function ConfigSection({
|
||||
)}
|
||||
{hasChanges && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{t("modified", {
|
||||
{t("button.modified", {
|
||||
ns: "common",
|
||||
defaultValue: "Modified",
|
||||
})}
|
||||
@@ -1210,7 +1210,10 @@ export function ConfigSection({
|
||||
variant="secondary"
|
||||
className="cursor-default bg-danger text-xs text-white hover:bg-danger"
|
||||
>
|
||||
{t("modified", { ns: "common", defaultValue: "Modified" })}
|
||||
{t("button.modified", {
|
||||
ns: "common",
|
||||
defaultValue: "Modified",
|
||||
})}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,11 @@ import type {
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { LuCircleAlert } from "react-icons/lu";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { buildTranslationPath, humanizeKey } from "../utils";
|
||||
import {
|
||||
buildTranslationPath,
|
||||
resolveConfigTranslation,
|
||||
humanizeKey,
|
||||
} from "../utils";
|
||||
import type { ConfigFormContext } from "@/types/configForm";
|
||||
|
||||
type ErrorSchemaNode = RJSFSchema & {
|
||||
@@ -114,22 +118,15 @@ const resolveErrorFieldLabel = ({
|
||||
);
|
||||
|
||||
if (effectiveNamespace && translationPath) {
|
||||
const prefixedTranslationKey =
|
||||
sectionI18nPrefix && !translationPath.startsWith(`${sectionI18nPrefix}.`)
|
||||
? `${sectionI18nPrefix}.${translationPath}.label`
|
||||
: undefined;
|
||||
const translationKey = `${translationPath}.label`;
|
||||
|
||||
if (
|
||||
prefixedTranslationKey &&
|
||||
i18n.exists(prefixedTranslationKey, { ns: effectiveNamespace })
|
||||
) {
|
||||
return t(prefixedTranslationKey, { ns: effectiveNamespace });
|
||||
}
|
||||
|
||||
if (i18n.exists(translationKey, { ns: effectiveNamespace })) {
|
||||
return t(translationKey, { ns: effectiveNamespace });
|
||||
}
|
||||
const translated = resolveConfigTranslation(
|
||||
i18n,
|
||||
t,
|
||||
translationPath,
|
||||
"label",
|
||||
sectionI18nPrefix,
|
||||
effectiveNamespace,
|
||||
);
|
||||
if (translated) return translated;
|
||||
}
|
||||
|
||||
const schemaNode = resolveSchemaNodeForPath(schema, segments);
|
||||
|
||||
@@ -20,6 +20,7 @@ import { requiresRestartForFieldPath } from "@/utils/configUtil";
|
||||
import RestartRequiredIndicator from "@/components/indicators/RestartRequiredIndicator";
|
||||
import {
|
||||
buildTranslationPath,
|
||||
resolveConfigTranslation,
|
||||
getFilterObjectLabel,
|
||||
hasOverrideAtPath,
|
||||
humanizeKey,
|
||||
@@ -219,20 +220,16 @@ export function FieldTemplate(props: FieldTemplateProps) {
|
||||
// Try to get translated label, falling back to schema title, then RJSF label
|
||||
let finalLabel = label;
|
||||
if (effectiveNamespace && translationPath) {
|
||||
// Prefer camera-scoped translations when a section prefix is provided
|
||||
const prefixedTranslationKey =
|
||||
sectionI18nPrefix && !translationPath.startsWith(`${sectionI18nPrefix}.`)
|
||||
? `${sectionI18nPrefix}.${translationPath}.label`
|
||||
: undefined;
|
||||
const translationKey = `${translationPath}.label`;
|
||||
|
||||
if (
|
||||
prefixedTranslationKey &&
|
||||
i18n.exists(prefixedTranslationKey, { ns: effectiveNamespace })
|
||||
) {
|
||||
finalLabel = t(prefixedTranslationKey, { ns: effectiveNamespace });
|
||||
} else if (i18n.exists(translationKey, { ns: effectiveNamespace })) {
|
||||
finalLabel = t(translationKey, { ns: effectiveNamespace });
|
||||
const translatedLabel = resolveConfigTranslation(
|
||||
i18n,
|
||||
t,
|
||||
translationPath,
|
||||
"label",
|
||||
sectionI18nPrefix,
|
||||
effectiveNamespace,
|
||||
);
|
||||
if (translatedLabel) {
|
||||
finalLabel = translatedLabel;
|
||||
} else if (schemaTitle) {
|
||||
finalLabel = schemaTitle;
|
||||
} else if (translatedFilterObjectLabel) {
|
||||
@@ -330,18 +327,16 @@ export function FieldTemplate(props: FieldTemplateProps) {
|
||||
// Try to get translated description, falling back to schema description
|
||||
let finalDescription = description || "";
|
||||
if (effectiveNamespace && translationPath) {
|
||||
const prefixedDescriptionKey =
|
||||
sectionI18nPrefix && !translationPath.startsWith(`${sectionI18nPrefix}.`)
|
||||
? `${sectionI18nPrefix}.${translationPath}.description`
|
||||
: undefined;
|
||||
const descriptionKey = `${translationPath}.description`;
|
||||
if (
|
||||
prefixedDescriptionKey &&
|
||||
i18n.exists(prefixedDescriptionKey, { ns: effectiveNamespace })
|
||||
) {
|
||||
finalDescription = t(prefixedDescriptionKey, { ns: effectiveNamespace });
|
||||
} else if (i18n.exists(descriptionKey, { ns: effectiveNamespace })) {
|
||||
finalDescription = t(descriptionKey, { ns: effectiveNamespace });
|
||||
const translatedDescription = resolveConfigTranslation(
|
||||
i18n,
|
||||
t,
|
||||
translationPath,
|
||||
"description",
|
||||
sectionI18nPrefix,
|
||||
effectiveNamespace,
|
||||
);
|
||||
if (translatedDescription) {
|
||||
finalDescription = translatedDescription;
|
||||
} else if (schemaDescription) {
|
||||
finalDescription = schemaDescription;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { requiresRestartForFieldPath } from "@/utils/configUtil";
|
||||
import { ConfigFormContext } from "@/types/configForm";
|
||||
import {
|
||||
buildTranslationPath,
|
||||
resolveConfigTranslation,
|
||||
getDomainFromNamespace,
|
||||
getFilterObjectLabel,
|
||||
humanizeKey,
|
||||
@@ -263,16 +264,14 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
|
||||
|
||||
let inferredLabel: string | undefined;
|
||||
if (i18nNs && translationPath) {
|
||||
const prefixedLabelKey =
|
||||
sectionI18nPrefix && !translationPath.startsWith(`${sectionI18nPrefix}.`)
|
||||
? `${sectionI18nPrefix}.${translationPath}.label`
|
||||
: undefined;
|
||||
const labelKey = `${translationPath}.label`;
|
||||
if (prefixedLabelKey && i18n.exists(prefixedLabelKey, { ns: i18nNs })) {
|
||||
inferredLabel = t(prefixedLabelKey, { ns: i18nNs });
|
||||
} else if (i18n.exists(labelKey, { ns: i18nNs })) {
|
||||
inferredLabel = t(labelKey, { ns: i18nNs });
|
||||
}
|
||||
inferredLabel = resolveConfigTranslation(
|
||||
i18n,
|
||||
t,
|
||||
translationPath,
|
||||
"label",
|
||||
sectionI18nPrefix,
|
||||
i18nNs,
|
||||
);
|
||||
}
|
||||
if (!inferredLabel && translatedFilterLabel) {
|
||||
inferredLabel = translatedFilterLabel;
|
||||
@@ -286,19 +285,14 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
|
||||
|
||||
let inferredDescription: string | undefined;
|
||||
if (i18nNs && translationPath) {
|
||||
const prefixedDescriptionKey =
|
||||
sectionI18nPrefix && !translationPath.startsWith(`${sectionI18nPrefix}.`)
|
||||
? `${sectionI18nPrefix}.${translationPath}.description`
|
||||
: undefined;
|
||||
const descriptionKey = `${translationPath}.description`;
|
||||
if (
|
||||
prefixedDescriptionKey &&
|
||||
i18n.exists(prefixedDescriptionKey, { ns: i18nNs })
|
||||
) {
|
||||
inferredDescription = t(prefixedDescriptionKey, { ns: i18nNs });
|
||||
} else if (i18n.exists(descriptionKey, { ns: i18nNs })) {
|
||||
inferredDescription = t(descriptionKey, { ns: i18nNs });
|
||||
}
|
||||
inferredDescription = resolveConfigTranslation(
|
||||
i18n,
|
||||
t,
|
||||
translationPath,
|
||||
"description",
|
||||
sectionI18nPrefix,
|
||||
i18nNs,
|
||||
);
|
||||
}
|
||||
const schemaDescription = schema?.description;
|
||||
const fallbackDescription =
|
||||
|
||||
@@ -124,6 +124,50 @@ export function buildTranslationPath(
|
||||
return stringSegments.join(".");
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a translated label or description for a config form field.
|
||||
*
|
||||
* Tries keys in priority order:
|
||||
* 1. Type-specific prefixed key (e.g. "detectors.edgetpu.device.label")
|
||||
* 2. Shared prefixed key with type stripped (e.g. "detectors.device.label")
|
||||
* 3. Unprefixed key (e.g. "device.label")
|
||||
*
|
||||
* @returns The translated string, or undefined if no key matched.
|
||||
*/
|
||||
export function resolveConfigTranslation(
|
||||
i18n: { exists: (key: string, opts?: Record<string, unknown>) => boolean },
|
||||
t: (key: string, opts?: Record<string, unknown>) => string,
|
||||
translationPath: string,
|
||||
suffix: "label" | "description",
|
||||
sectionI18nPrefix?: string,
|
||||
ns?: string,
|
||||
): string | undefined {
|
||||
const opts = ns ? { ns } : undefined;
|
||||
|
||||
if (
|
||||
sectionI18nPrefix &&
|
||||
!translationPath.startsWith(`${sectionI18nPrefix}.`)
|
||||
) {
|
||||
// 1. Type-specific prefixed key (e.g. detectors.edgetpu.device.label)
|
||||
const prefixed = `${sectionI18nPrefix}.${translationPath}.${suffix}`;
|
||||
if (i18n.exists(prefixed, opts)) return t(prefixed, opts);
|
||||
|
||||
// 2. Shared prefixed key — strip leading type segment
|
||||
// e.g. detectors.edgetpu.model.path → detectors.model.path
|
||||
const dot = translationPath.indexOf(".");
|
||||
if (dot !== -1) {
|
||||
const shared = `${sectionI18nPrefix}.${translationPath.substring(dot + 1)}.${suffix}`;
|
||||
if (i18n.exists(shared, opts)) return t(shared, opts);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Unprefixed key
|
||||
const base = `${translationPath}.${suffix}`;
|
||||
if (i18n.exists(base, opts)) return t(base, opts);
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the filter object label from a path containing "filters" segment.
|
||||
* Returns the segment immediately after "filters".
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
export {
|
||||
buildTranslationPath,
|
||||
resolveConfigTranslation,
|
||||
getFilterObjectLabel,
|
||||
humanizeKey,
|
||||
getDomainFromNamespace,
|
||||
|
||||
@@ -207,7 +207,10 @@ export function SingleSectionPage({
|
||||
variant="secondary"
|
||||
className="cursor-default bg-danger text-xs text-white hover:bg-danger"
|
||||
>
|
||||
{t("modified", { ns: "common", defaultValue: "Modified" })}
|
||||
{t("button.modified", {
|
||||
ns: "common",
|
||||
defaultValue: "Modified",
|
||||
})}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
@@ -242,7 +245,7 @@ export function SingleSectionPage({
|
||||
variant="secondary"
|
||||
className="cursor-default bg-danger text-xs text-white hover:bg-danger"
|
||||
>
|
||||
{t("modified", { ns: "common", defaultValue: "Modified" })}
|
||||
{t("button.modified", { ns: "common", defaultValue: "Modified" })}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user