feat(metadata): add original-language preferences (#526)
* feat(metadata): add original-language preferences * fix(settings): show metadata exceptions immediately * docs(settings): add metadata language screenshot * fix(settings): make language exceptions responsive * fix(settings): standardize language display names
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ORIGINAL_METADATA_LANGUAGE } from "@/lib/metadataLanguagePreferences";
|
||||
import { MetadataLanguageSetting } from "./MetadataLanguageSetting";
|
||||
|
||||
const languageOptions = [
|
||||
{ value: "en", label: "English" },
|
||||
{ value: "ja", label: "Japanese" },
|
||||
{ value: "no", label: "Norwegian" },
|
||||
];
|
||||
|
||||
class ResizeObserverStub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
globalThis.ResizeObserver ??= ResizeObserverStub as unknown as typeof ResizeObserver;
|
||||
window.HTMLElement.prototype.hasPointerCapture ??= () => false;
|
||||
window.HTMLElement.prototype.scrollIntoView ??= () => {};
|
||||
|
||||
describe("MetadataLanguageSetting", () => {
|
||||
afterEach(cleanup);
|
||||
|
||||
it("shows a source-language exception and removes only that rule", () => {
|
||||
const onOverridesChange = vi.fn();
|
||||
render(
|
||||
<MetadataLanguageSetting
|
||||
fallback="en"
|
||||
overrides={{ no: ORIGINAL_METADATA_LANGUAGE, ja: "en" }}
|
||||
languageOptions={languageOptions}
|
||||
onFallbackChange={vi.fn()}
|
||||
onOverridesChange={onOverridesChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Norwegian")).toBeTruthy();
|
||||
expect(screen.getByText("Japanese")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Remove Norwegian exception" }));
|
||||
|
||||
expect(onOverridesChange).toHaveBeenCalledWith({ ja: "en" });
|
||||
});
|
||||
|
||||
it("keeps the add action disabled until an original language is chosen", () => {
|
||||
render(
|
||||
<MetadataLanguageSetting
|
||||
fallback={ORIGINAL_METADATA_LANGUAGE}
|
||||
overrides={{}}
|
||||
languageOptions={languageOptions}
|
||||
onFallbackChange={vi.fn()}
|
||||
onOverridesChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Add exception" }).hasAttribute("disabled")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(screen.getByLabelText("Original language for new exception")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows a new exception immediately while its save is still pending", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOverridesChange = vi.fn(() => new Promise<void>(() => {}));
|
||||
render(
|
||||
<MetadataLanguageSetting
|
||||
fallback="en"
|
||||
overrides={{}}
|
||||
languageOptions={languageOptions}
|
||||
onFallbackChange={vi.fn()}
|
||||
onOverridesChange={onOverridesChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByLabelText("Original language for new exception"));
|
||||
await user.click(screen.getByRole("option", { name: "Norwegian" }));
|
||||
await user.click(screen.getByRole("button", { name: "Add exception" }));
|
||||
|
||||
expect(screen.getByLabelText("Metadata language for Norwegian")).toHaveTextContent(
|
||||
"Original language",
|
||||
);
|
||||
expect(onOverridesChange).toHaveBeenCalledWith({ no: ORIGINAL_METADATA_LANGUAGE });
|
||||
});
|
||||
|
||||
it("rolls back an optimistic exception when its save fails", async () => {
|
||||
const user = userEvent.setup();
|
||||
let rejectSave: (reason: Error) => void = () => {};
|
||||
const onOverridesChange = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((_, reject) => {
|
||||
rejectSave = reject;
|
||||
}),
|
||||
);
|
||||
render(
|
||||
<MetadataLanguageSetting
|
||||
fallback="en"
|
||||
overrides={{}}
|
||||
languageOptions={languageOptions}
|
||||
onFallbackChange={vi.fn()}
|
||||
onOverridesChange={onOverridesChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByLabelText("Original language for new exception"));
|
||||
await user.click(screen.getByRole("option", { name: "Norwegian" }));
|
||||
await user.click(screen.getByRole("button", { name: "Add exception" }));
|
||||
expect(screen.getByLabelText("Metadata language for Norwegian")).toBeInTheDocument();
|
||||
|
||||
rejectSave(new Error("save failed"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByLabelText("Metadata language for Norwegian")).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a custom target selectable when it is outside the advisory catalog", () => {
|
||||
render(
|
||||
<MetadataLanguageSetting
|
||||
fallback="en"
|
||||
overrides={{ no: "pt-BR" }}
|
||||
languageOptions={languageOptions}
|
||||
onFallbackChange={vi.fn()}
|
||||
onOverridesChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText("Metadata language for Norwegian").textContent).toContain(
|
||||
"Portuguese",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
|
||||
import { SettingRow } from "@/components/settings/SettingRow";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { withCurrentLanguageOption, type SettingOption } from "@/lib/languageOptions";
|
||||
import { getLanguageName } from "@/lib/languageNames";
|
||||
import {
|
||||
ORIGINAL_METADATA_LANGUAGE,
|
||||
withMetadataLanguageOverride,
|
||||
withoutMetadataLanguageOverride,
|
||||
type MetadataLanguageOverrides,
|
||||
} from "@/lib/metadataLanguagePreferences";
|
||||
|
||||
interface MetadataLanguageSettingProps {
|
||||
fallback: string | null;
|
||||
overrides: MetadataLanguageOverrides;
|
||||
languageOptions: readonly SettingOption[];
|
||||
disabled?: boolean;
|
||||
onFallbackChange: (language: string | null) => void;
|
||||
onOverridesChange: (overrides: MetadataLanguageOverrides) => void | Promise<void>;
|
||||
}
|
||||
|
||||
const NO_PREFERENCE = "__library_default";
|
||||
|
||||
function overridesEqual(
|
||||
left: MetadataLanguageOverrides,
|
||||
right: MetadataLanguageOverrides,
|
||||
): boolean {
|
||||
const leftEntries = Object.entries(left);
|
||||
return (
|
||||
leftEntries.length === Object.keys(right).length &&
|
||||
leftEntries.every(([source, target]) => right[source] === target)
|
||||
);
|
||||
}
|
||||
|
||||
export function MetadataLanguageSetting({
|
||||
fallback,
|
||||
overrides,
|
||||
languageOptions,
|
||||
disabled = false,
|
||||
onFallbackChange,
|
||||
onOverridesChange,
|
||||
}: MetadataLanguageSettingProps) {
|
||||
const [newSource, setNewSource] = useState("");
|
||||
const [optimisticOverrides, setOptimisticOverrides] = useState<{
|
||||
base: MetadataLanguageOverrides;
|
||||
value: MetadataLanguageOverrides;
|
||||
} | null>(null);
|
||||
const currentOverrides =
|
||||
optimisticOverrides !== null && overridesEqual(overrides, optimisticOverrides.base)
|
||||
? optimisticOverrides.value
|
||||
: overrides;
|
||||
|
||||
const entries = useMemo(
|
||||
() =>
|
||||
Object.entries(currentOverrides).sort(([left], [right]) =>
|
||||
getLanguageName(left).localeCompare(getLanguageName(right)),
|
||||
),
|
||||
[currentOverrides],
|
||||
);
|
||||
const namedOptions = useMemo(
|
||||
() => languageOptions.filter((language) => language.value !== ORIGINAL_METADATA_LANGUAGE),
|
||||
[languageOptions],
|
||||
);
|
||||
const availableSources = namedOptions.filter((language) => !(language.value in currentOverrides));
|
||||
const optionsForTarget = (target: string) => {
|
||||
if (target === ORIGINAL_METADATA_LANGUAGE) return namedOptions;
|
||||
return withCurrentLanguageOption(namedOptions, target);
|
||||
};
|
||||
|
||||
const changeOverrides = async (next: MetadataLanguageOverrides) => {
|
||||
const pending = { base: overrides, value: next };
|
||||
setOptimisticOverrides(pending);
|
||||
try {
|
||||
await onOverridesChange(next);
|
||||
} catch {
|
||||
setOptimisticOverrides((current) => (current === pending ? null : current));
|
||||
}
|
||||
};
|
||||
|
||||
const addException = () => {
|
||||
if (!newSource) return;
|
||||
void changeOverrides(
|
||||
withMetadataLanguageOverride(currentOverrides, newSource, ORIGINAL_METADATA_LANGUAGE),
|
||||
);
|
||||
setNewSource("");
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingRow
|
||||
label="Metadata language"
|
||||
description="Choose the fallback for titles and descriptions, then add exceptions based on each item's original language. Missing descriptions can be translated automatically when AI translation is enabled."
|
||||
control={(id) => (
|
||||
<div className="w-full min-w-0 space-y-3 md:w-[430px]">
|
||||
<Select
|
||||
value={fallback ?? NO_PREFERENCE}
|
||||
onValueChange={(value) => onFallbackChange(value === NO_PREFERENCE ? null : value)}
|
||||
>
|
||||
<SelectTrigger id={id} className="w-full" disabled={disabled}>
|
||||
<SelectValue placeholder="Library default" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NO_PREFERENCE}>Library default</SelectItem>
|
||||
<SelectItem value={ORIGINAL_METADATA_LANGUAGE}>Original language</SelectItem>
|
||||
{namedOptions.map((language) => (
|
||||
<SelectItem key={language.value} value={language.value}>
|
||||
{language.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{entries.length > 0 ? (
|
||||
<div className="border-border/60 divide-border/60 divide-y border-y">
|
||||
{entries.map(([source, target]) => (
|
||||
<div
|
||||
key={source}
|
||||
className="grid min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-2 py-2 sm:grid-cols-[minmax(0,1fr)_minmax(0,1.35fr)_auto]"
|
||||
>
|
||||
<span className="truncate text-sm">{getLanguageName(source)}</span>
|
||||
<Select
|
||||
value={target}
|
||||
onValueChange={(value) =>
|
||||
void changeOverrides(
|
||||
withMetadataLanguageOverride(currentOverrides, source, value),
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label={`Metadata language for ${getLanguageName(source)}`}
|
||||
className="col-span-2 col-start-1 row-start-2 h-9 w-full sm:col-span-1 sm:col-start-2 sm:row-start-1 sm:h-8"
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ORIGINAL_METADATA_LANGUAGE}>Original language</SelectItem>
|
||||
{optionsForTarget(target).map((language) => (
|
||||
<SelectItem key={language.value} value={language.value}>
|
||||
{language.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="col-start-2 row-start-1 size-9 sm:col-start-3 sm:size-8"
|
||||
disabled={disabled}
|
||||
aria-label={`Remove ${getLanguageName(source)} exception`}
|
||||
onClick={() =>
|
||||
void changeOverrides(
|
||||
withoutMetadataLanguageOverride(currentOverrides, source),
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-3.5" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{availableSources.length > 0 ? (
|
||||
<div className="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<Select value={newSource} onValueChange={setNewSource}>
|
||||
<SelectTrigger
|
||||
aria-label="Original language for new exception"
|
||||
className="h-10 w-full min-w-0 flex-1 sm:h-9 sm:w-auto"
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectValue placeholder="Choose original language" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableSources.map((language) => (
|
||||
<SelectItem key={language.value} value={language.value}>
|
||||
{language.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-10 w-full sm:h-8 sm:w-auto"
|
||||
disabled={disabled || !newSource}
|
||||
onClick={addException}
|
||||
>
|
||||
<Plus className="mr-1.5 size-3.5" aria-hidden="true" />
|
||||
Add exception
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +1 @@
|
||||
// Catalog filter values for `original_language` are ISO 639-1 codes ("en",
|
||||
// "fr", "ja"). Surfaces show friendly names ("English", "French", "Japanese")
|
||||
// while keeping the underlying value untouched so query rules stay valid.
|
||||
|
||||
let cachedDisplayNames: Intl.DisplayNames | null | undefined;
|
||||
|
||||
function getDisplayNames(): Intl.DisplayNames | null {
|
||||
if (cachedDisplayNames !== undefined) {
|
||||
return cachedDisplayNames;
|
||||
}
|
||||
try {
|
||||
cachedDisplayNames = new Intl.DisplayNames(["en"], { type: "language" });
|
||||
} catch {
|
||||
cachedDisplayNames = null;
|
||||
}
|
||||
return cachedDisplayNames;
|
||||
}
|
||||
|
||||
export function formatLanguage(code: string): string {
|
||||
const trimmed = code.trim();
|
||||
if (!trimmed) return "";
|
||||
const dn = getDisplayNames();
|
||||
if (!dn) return trimmed.toUpperCase();
|
||||
try {
|
||||
const name = dn.of(trimmed);
|
||||
if (name && name !== trimmed) {
|
||||
// Intl.DisplayNames returns lowercase for some locales — capitalize.
|
||||
return name.charAt(0).toUpperCase() + name.slice(1);
|
||||
}
|
||||
} catch {
|
||||
// Fall through to fallback.
|
||||
}
|
||||
return trimmed.toUpperCase();
|
||||
}
|
||||
export { formatLanguage } from "@/lib/languageNames";
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
canonicalLanguageTag,
|
||||
englishLanguageName,
|
||||
getLanguageName,
|
||||
normalizeLanguageCode,
|
||||
} from "./languageNames";
|
||||
|
||||
describe("languageNames", () => {
|
||||
it("uses bundled English CLDR names for ISO 639-1 and ISO 639-3 values", () => {
|
||||
expect(englishLanguageName("sa")).toBe("Sanskrit");
|
||||
expect(englishLanguageName("se")).toBe("Northern Sami");
|
||||
expect(englishLanguageName("sm")).toBe("Samoan");
|
||||
expect(englishLanguageName("yue")).toBe("Cantonese");
|
||||
});
|
||||
|
||||
it("preserves region and script specificity in names", () => {
|
||||
expect(englishLanguageName("pt-BR")).toBe("Brazilian Portuguese");
|
||||
expect(englishLanguageName("sr-Latn")).toBe("Serbian (Latin)");
|
||||
});
|
||||
|
||||
it("canonicalizes ISO aliases without changing regional identity", () => {
|
||||
expect(canonicalLanguageTag("eng")).toBe("en");
|
||||
expect(canonicalLanguageTag("pt_BR")).toBe("pt-BR");
|
||||
expect(normalizeLanguageCode("fre-CA")).toBe("fr");
|
||||
});
|
||||
|
||||
it("distinguishes an unassigned tag from a translated language name", () => {
|
||||
expect(englishLanguageName("xx")).toBeNull();
|
||||
expect(getLanguageName("xx")).toBe("Unknown language (xx)");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import "@formatjs/intl-displaynames/polyfill-force.js";
|
||||
import "@formatjs/intl-displaynames/locale-data/en.js";
|
||||
|
||||
const englishLanguageNames = new Intl.DisplayNames(["en"], {
|
||||
type: "language",
|
||||
fallback: "none",
|
||||
});
|
||||
const englishScriptNames = new Intl.DisplayNames(["en"], {
|
||||
type: "script",
|
||||
fallback: "none",
|
||||
});
|
||||
const englishRegionNames = new Intl.DisplayNames(["en"], {
|
||||
type: "region",
|
||||
fallback: "none",
|
||||
});
|
||||
|
||||
function displayName(names: Intl.DisplayNames, value: string): string | null {
|
||||
try {
|
||||
return names.of(value) ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Canonical BCP 47 identity used only for comparison; wire values stay untouched. */
|
||||
export function canonicalLanguageTag(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
return new Intl.Locale(trimmed.replace(/_/g, "-")).toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Stable identity that de-duplicates ISO aliases without collapsing script or region subtags. */
|
||||
export function languageIdentity(value: string): string {
|
||||
return canonicalLanguageTag(value) ?? value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a deterministic English CLDR name for an ISO/BCP 47 value.
|
||||
*
|
||||
* Some CLDR releases omit a precomposed language-and-script label. In that
|
||||
* case, compose the independently standardized language, script, and region
|
||||
* names so tags such as `sr-Latn` remain distinct from `sr`.
|
||||
*/
|
||||
export function englishLanguageName(value: string): string | null {
|
||||
const canonical = canonicalLanguageTag(value);
|
||||
if (!canonical) return null;
|
||||
|
||||
let locale: Intl.Locale;
|
||||
try {
|
||||
locale = new Intl.Locale(canonical);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const language = displayName(englishLanguageNames, locale.language);
|
||||
if (!language) return null;
|
||||
|
||||
const exact = displayName(englishLanguageNames, canonical);
|
||||
const qualifiers = [
|
||||
locale.script ? displayName(englishScriptNames, locale.script) : null,
|
||||
locale.region ? displayName(englishRegionNames, locale.region) : null,
|
||||
].filter((part): part is string => Boolean(part));
|
||||
|
||||
if (exact && (qualifiers.length === 0 || exact !== language)) return exact;
|
||||
return qualifiers.length > 0 ? `${language} (${qualifiers.join(", ")})` : language;
|
||||
}
|
||||
|
||||
/** User-facing name with an explicit fallback for unassigned or invalid tags. */
|
||||
export function getLanguageName(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return "Unknown";
|
||||
return englishLanguageName(trimmed) ?? `Unknown language (${trimmed})`;
|
||||
}
|
||||
|
||||
/** Empty-preserving variant for optional metadata and filter labels. */
|
||||
export function formatLanguage(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? getLanguageName(trimmed) : "";
|
||||
}
|
||||
|
||||
/** Canonical ISO language subtag used for language matching and override keys. */
|
||||
export function normalizeLanguageCode(value: string | null | undefined): string {
|
||||
const canonical = canonicalLanguageTag(value ?? "");
|
||||
if (!canonical) return "";
|
||||
return new Intl.Locale(canonical).language;
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { SETTING_KEYS } from "./settingsContract";
|
||||
import { languageOptionsFor, namedLanguageOptionsFor } from "./languageOptions";
|
||||
import {
|
||||
languageOptionsFor,
|
||||
namedLanguageOptionsFor,
|
||||
withCurrentLanguageOption,
|
||||
} from "./languageOptions";
|
||||
|
||||
describe("languageOptions", () => {
|
||||
it("uses the definition-specific generated option set", () => {
|
||||
@@ -26,6 +30,25 @@ describe("languageOptions", () => {
|
||||
expect(options.map((option) => option.value)).toContain("pt");
|
||||
});
|
||||
|
||||
it("uses English names for ISO and BCP 47 values instead of exposing raw tags", () => {
|
||||
const options = namedLanguageOptionsFor(SETTING_KEYS.CATALOG_METADATA_LANGUAGE, undefined, [
|
||||
"sa",
|
||||
"se",
|
||||
"yue",
|
||||
"pt-BR",
|
||||
"sr-Latn",
|
||||
"xx",
|
||||
]);
|
||||
const labels = Object.fromEntries(options.map((option) => [option.value, option.label]));
|
||||
|
||||
expect(labels.sa).toBe("Sanskrit");
|
||||
expect(labels.se).toBe("Northern Sami");
|
||||
expect(labels.yue).toBe("Cantonese");
|
||||
expect(labels["pt-BR"]).toBe("Brazilian Portuguese");
|
||||
expect(labels["sr-Latn"]).toBe("Serbian (Latin)");
|
||||
expect(labels.xx).toBe("Unknown language (xx)");
|
||||
});
|
||||
|
||||
it("uses each nullable definition's context-specific unset copy", () => {
|
||||
expect(languageOptionsFor(SETTING_KEYS.PLAYBACK_AUDIO_LANGUAGE)[0]).toEqual({
|
||||
value: "",
|
||||
@@ -40,4 +63,19 @@ describe("languageOptions", () => {
|
||||
label: "Library default",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps an exact current ISO alias without showing its semantic duplicate", () => {
|
||||
const options = withCurrentLanguageOption(
|
||||
[
|
||||
{ value: "en", label: "English" },
|
||||
{ value: "fr", label: "French" },
|
||||
],
|
||||
"eng",
|
||||
);
|
||||
|
||||
expect(options).toEqual([
|
||||
{ value: "eng", label: "English" },
|
||||
{ value: "fr", label: "French" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
SETTINGS_REVISION,
|
||||
type SettingKey,
|
||||
} from "@/lib/settingsContract";
|
||||
import { getLanguageName, languageIdentity } from "@/lib/languageNames";
|
||||
|
||||
/** One choice in a settings dropdown. */
|
||||
export interface SettingOption {
|
||||
@@ -11,24 +12,21 @@ export interface SettingOption {
|
||||
label: string;
|
||||
}
|
||||
|
||||
const languageNames = new Intl.DisplayNames(undefined, { type: "language" });
|
||||
/** Preserve an exact open value while replacing any semantic ISO alias already shown. */
|
||||
export function withCurrentLanguageOption(
|
||||
options: readonly SettingOption[],
|
||||
currentValue: string,
|
||||
): SettingOption[] {
|
||||
const currentIdentity = languageIdentity(currentValue);
|
||||
const current = { value: currentValue, label: getLanguageName(currentValue) };
|
||||
const existing = options.findIndex(
|
||||
(option) => languageIdentity(option.value) === currentIdentity,
|
||||
);
|
||||
if (existing < 0) return [current, ...options];
|
||||
|
||||
function languageIdentity(value: string): string {
|
||||
try {
|
||||
// Canonicalize true aliases (eng/en) without discarding meaningful
|
||||
// script or region specificity (pt/pt-BR).
|
||||
return new Intl.Locale(value).toString();
|
||||
} catch {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
function languageLabel(value: string): string {
|
||||
try {
|
||||
return languageNames.of(value) ?? value;
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
const merged = [...options];
|
||||
merged[existing] = current;
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,7 +71,7 @@ export function namedLanguageOptionsFor(
|
||||
runtimeValues.forEach((value) => add(value, false));
|
||||
if (currentValue) add(currentValue, true);
|
||||
|
||||
return values.map((value) => ({ value, label: languageLabel(value) }));
|
||||
return values.map((value) => ({ value, label: getLanguageName(value) }));
|
||||
}
|
||||
|
||||
/** The nullable list, using the contract's context-specific unset copy. */
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
ORIGINAL_METADATA_LANGUAGE,
|
||||
normalizeMetadataLanguageOverrides,
|
||||
withMetadataLanguageOverride,
|
||||
withoutMetadataLanguageOverride,
|
||||
} from "./metadataLanguagePreferences";
|
||||
|
||||
describe("metadata language preferences", () => {
|
||||
it("normalizes source aliases and drops malformed cached entries", () => {
|
||||
expect(
|
||||
normalizeMetadataLanguageOverrides({
|
||||
nor: ORIGINAL_METADATA_LANGUAGE,
|
||||
JA: "en",
|
||||
invalid_language: "fr",
|
||||
de: 42,
|
||||
}),
|
||||
).toEqual({ no: ORIGINAL_METADATA_LANGUAGE, ja: "en" });
|
||||
});
|
||||
|
||||
it("adds and removes one original-language exception without mutating the input", () => {
|
||||
const initial = { ja: "en" };
|
||||
const added = withMetadataLanguageOverride(initial, "nor", ORIGINAL_METADATA_LANGUAGE);
|
||||
|
||||
expect(added).toEqual({ ja: "en", no: ORIGINAL_METADATA_LANGUAGE });
|
||||
expect(initial).toEqual({ ja: "en" });
|
||||
expect(withoutMetadataLanguageOverride(added, "no")).toEqual({ ja: "en" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { normalizeLanguageCode } from "@/lib/languageNames";
|
||||
|
||||
/** Valid private-use BCP 47 tag understood by the server as item-original. */
|
||||
export const ORIGINAL_METADATA_LANGUAGE = "x-silo-original";
|
||||
|
||||
export type MetadataLanguageOverrides = Record<string, string>;
|
||||
|
||||
/**
|
||||
* Treat the settings response as untrusted JSON at the component boundary.
|
||||
* The server validates writes, but this also keeps a stale/broken cached value
|
||||
* from making the editor unusable.
|
||||
*/
|
||||
export function normalizeMetadataLanguageOverrides(value: unknown): MetadataLanguageOverrides {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value)) return {};
|
||||
|
||||
const normalized: MetadataLanguageOverrides = {};
|
||||
for (const [source, target] of Object.entries(value)) {
|
||||
const sourceCode = normalizeLanguageCode(source);
|
||||
if (!/^[a-z]{2,3}$/.test(sourceCode) || typeof target !== "string" || !target.trim()) {
|
||||
continue;
|
||||
}
|
||||
normalized[sourceCode] = target.trim();
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function withMetadataLanguageOverride(
|
||||
overrides: MetadataLanguageOverrides,
|
||||
source: string,
|
||||
target: string,
|
||||
): MetadataLanguageOverrides {
|
||||
const sourceCode = normalizeLanguageCode(source);
|
||||
if (!/^[a-z]{2,3}$/.test(sourceCode) || !target.trim()) return overrides;
|
||||
return { ...overrides, [sourceCode]: target.trim() };
|
||||
}
|
||||
|
||||
export function withoutMetadataLanguageOverride(
|
||||
overrides: MetadataLanguageOverrides,
|
||||
source: string,
|
||||
): MetadataLanguageOverrides {
|
||||
const next = { ...overrides };
|
||||
delete next[normalizeLanguageCode(source)];
|
||||
return next;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"fixture_version": 1,
|
||||
"manifest_revision": 2,
|
||||
"manifest_revision": 3,
|
||||
"description": "Cross-platform conformance cases for settings resolution. Every case runs against the shipped manifest in this directory: definitions are referenced by key, never restated, so an expectation can only be satisfied by resolving the real contract. Each platform's resolver (Go in internal/settingsresolve, TypeScript in web/src/lib/settingsResolve.ts, Kotlin and Swift in the client repos) runs every case through a hand-written runner; a runner must fail on any fixture field it does not know, because schema drift in the fixture itself is drift. A case's constraint_bindings attach a constraint to a copy of a real definition so constraint semantics stay testable even while no shipped definition carries that constraint kind. In expected entries, constrained:true requires stored_value and constraint_kind to be present, and stored_value may be null to mean the authored value was JSON null.",
|
||||
"cases": [
|
||||
{
|
||||
@@ -438,6 +438,27 @@
|
||||
"constraints": { "allowed_metadata_languages": ["en", "fr"] },
|
||||
"expected": [{ "key": "catalog.metadata_language", "value": "fr", "source": "profile" }]
|
||||
},
|
||||
{
|
||||
"name": "metadata_language_exceptions_resolve_as_one_object",
|
||||
"description": "The original-language exception map is a single profile-scoped value; resolution preserves every source-to-target entry together.",
|
||||
"keys": ["catalog.metadata_language_overrides"],
|
||||
"context": { "profile_id": "p1" },
|
||||
"stored": [
|
||||
{
|
||||
"key": "catalog.metadata_language_overrides",
|
||||
"scope": "profile",
|
||||
"profile_id": "p1",
|
||||
"value": { "ja": "en", "no": "x-silo-original" }
|
||||
}
|
||||
],
|
||||
"expected": [
|
||||
{
|
||||
"key": "catalog.metadata_language_overrides",
|
||||
"value": { "ja": "en", "no": "x-silo-original" },
|
||||
"source": "profile"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "locked_replaces_a_differing_choice",
|
||||
"description": "locked is total: the policy value replaces the user's outright, and the authored choice is preserved as stored_value so it takes effect the day the lock lifts.",
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* is a manifest change plus a regeneration, never a hand-written key.
|
||||
*/
|
||||
|
||||
export const SETTINGS_REVISION = 2;
|
||||
export const SETTINGS_REVISION = 3;
|
||||
|
||||
export interface SettingSuggestedOption {
|
||||
value: string;
|
||||
@@ -154,6 +154,8 @@ export type SettingOptionSetId = keyof typeof SETTING_OPTION_SETS;
|
||||
export const SETTING_KEYS = {
|
||||
/** Metadata language */
|
||||
CATALOG_METADATA_LANGUAGE: "catalog.metadata_language",
|
||||
/** Metadata language exceptions */
|
||||
CATALOG_METADATA_LANGUAGE_OVERRIDES: "catalog.metadata_language_overrides",
|
||||
/** Download quality */
|
||||
DOWNLOADS_DEFAULT_QUALITY: "downloads.default_quality",
|
||||
/** Keep watched downloads */
|
||||
@@ -306,12 +308,27 @@ export const SETTING_DEFINITIONS: Record<SettingKey, SettingDefinition> = {
|
||||
resolutionOrder: ["profile", "default"],
|
||||
defaultValue: null,
|
||||
label: "Metadata language",
|
||||
description: "Language Silo prefers for titles, descriptions, and artwork.",
|
||||
description: "Fallback language Silo prefers for titles, descriptions, and artwork.",
|
||||
category: "catalog",
|
||||
control: "select",
|
||||
suggestedOptions: "catalog_metadata_languages",
|
||||
unsetLabel: "Library default",
|
||||
},
|
||||
"catalog.metadata_language_overrides": {
|
||||
key: "catalog.metadata_language_overrides",
|
||||
type: "object",
|
||||
nullable: false,
|
||||
persistence: "remote",
|
||||
introducedIn: 3,
|
||||
scopes: ["profile"],
|
||||
scopeIntroducedIn: [3],
|
||||
resolutionOrder: ["profile", "default"],
|
||||
defaultValue: {},
|
||||
label: "Metadata language exceptions",
|
||||
description: "Preferred metadata language for items in specific original languages.",
|
||||
category: "catalog",
|
||||
control: "panel",
|
||||
},
|
||||
"downloads.default_quality": {
|
||||
key: "downloads.default_quality",
|
||||
type: "enum",
|
||||
|
||||
@@ -26,6 +26,10 @@ describe("formatLanguageName", () => {
|
||||
expect(formatLanguageName("english subtitles")).toBe("English Subtitles");
|
||||
});
|
||||
|
||||
it("preserves an already readable language name", () => {
|
||||
expect(formatLanguageName("English")).toBe("English");
|
||||
});
|
||||
|
||||
it("returns empty string for undefined", () => {
|
||||
expect(formatLanguageName(undefined)).toBe("");
|
||||
});
|
||||
|
||||
@@ -1,19 +1,7 @@
|
||||
import type { VersionAudioTrack, VersionSubtitleTrack, VersionVideoTrack } from "@/api/types";
|
||||
import { englishLanguageName, getLanguageName } from "@/lib/languageNames";
|
||||
import { formatBitrate, formatChannels, formatSampleRate } from "@/lib/mediaFormat";
|
||||
|
||||
export const LANGUAGE_NAMES: Record<string, string> = {
|
||||
cze: "Czech",
|
||||
ces: "Czech",
|
||||
deu: "German",
|
||||
eng: "English",
|
||||
fra: "French",
|
||||
fre: "French",
|
||||
ita: "Italian",
|
||||
jpn: "Japanese",
|
||||
por: "Portuguese",
|
||||
spa: "Spanish",
|
||||
};
|
||||
|
||||
export function formatPageCount(pages?: number): string {
|
||||
if (!pages || pages <= 0) return "";
|
||||
return `${pages.toLocaleString()} ${pages === 1 ? "page" : "pages"}`;
|
||||
@@ -35,7 +23,9 @@ export function formatLanguageName(language?: string): string {
|
||||
const trimmed = language.trim();
|
||||
const normalized = trimmed.toLowerCase();
|
||||
if (!normalized) return "";
|
||||
if (LANGUAGE_NAMES[normalized]) return LANGUAGE_NAMES[normalized];
|
||||
|
||||
const standardizedName = englishLanguageName(trimmed);
|
||||
if (standardizedName) return standardizedName;
|
||||
|
||||
if (normalized.length > 3) {
|
||||
return trimmed
|
||||
@@ -44,18 +34,7 @@ export function formatLanguageName(language?: string): string {
|
||||
.map((part) => part[0]?.toUpperCase() + part.slice(1).toLowerCase())
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
if (typeof Intl !== "undefined" && "DisplayNames" in Intl) {
|
||||
try {
|
||||
const displayNames = new Intl.DisplayNames(undefined, { type: "language" });
|
||||
const match = displayNames.of(normalized);
|
||||
if (match) return match;
|
||||
} catch {
|
||||
// Fall back to the raw code below.
|
||||
}
|
||||
}
|
||||
|
||||
return normalized.toUpperCase();
|
||||
return getLanguageName(trimmed);
|
||||
}
|
||||
|
||||
const SOURCE_HINT_PATTERNS: Array<{ pattern: RegExp; canonical: string }> = [
|
||||
|
||||
@@ -79,6 +79,7 @@ describe("PlaybackSettings", () => {
|
||||
expect(batched?.[0].keys).toContain(SETTING_KEYS.PLAYBACK_AUTO_SKIP_INTRO);
|
||||
expect(batched?.[0].keys).toContain(SETTING_KEYS.UI_NEXT_UP_MODE);
|
||||
expect(batched?.[0].keys).toContain(SETTING_KEYS.CATALOG_METADATA_LANGUAGE);
|
||||
expect(batched?.[0].keys).toContain(SETTING_KEYS.CATALOG_METADATA_LANGUAGE_OVERRIDES);
|
||||
});
|
||||
|
||||
it("saves a toggle as typed JSON at profile scope", () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { SettingsGroup } from "@/components/settings/SettingsGroup";
|
||||
import { SettingRow } from "@/components/settings/SettingRow";
|
||||
import { MetadataLanguageSetting } from "@/components/settings/MetadataLanguageSetting";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
@@ -11,6 +12,10 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { optionsFor } from "@/lib/settingsDisplay";
|
||||
import { namedLanguageOptionsFor } from "@/lib/languageOptions";
|
||||
import {
|
||||
normalizeMetadataLanguageOverrides,
|
||||
type MetadataLanguageOverrides,
|
||||
} from "@/lib/metadataLanguagePreferences";
|
||||
import { SETTING_DEFINITIONS, SETTING_KEYS, type SettingKey } from "@/lib/settingsContract";
|
||||
import { QUALITY_PRESETS, describeQuality, presetById, presetIdFor } from "@/lib/qualityPresets";
|
||||
import { useEffectiveSettings } from "@/hooks/queries/settingValues";
|
||||
@@ -38,6 +43,7 @@ const PLAYBACK_KEYS: SettingKey[] = [
|
||||
SETTING_KEYS.PLAYBACK_AUTO_SKIP_INTRO,
|
||||
SETTING_KEYS.PLAYBACK_AUTO_SKIP_RECAP,
|
||||
SETTING_KEYS.CATALOG_METADATA_LANGUAGE,
|
||||
SETTING_KEYS.CATALOG_METADATA_LANGUAGE_OVERRIDES,
|
||||
SETTING_KEYS.UI_NEXT_UP_MODE,
|
||||
];
|
||||
|
||||
@@ -190,6 +196,17 @@ export default function PlaybackSettings() {
|
||||
const nextUpChosen = effective?.[SETTING_KEYS.UI_NEXT_UP_MODE]?.source === "profile";
|
||||
const pending = isSaving;
|
||||
|
||||
const saveMetadataOverrides = (overrides: MetadataLanguageOverrides) => {
|
||||
const request =
|
||||
Object.keys(overrides).length === 0
|
||||
? resetProfileDefault(SETTING_KEYS.CATALOG_METADATA_LANGUAGE_OVERRIDES)
|
||||
: saveProfileDefault(SETTING_KEYS.CATALOG_METADATA_LANGUAGE_OVERRIDES, overrides);
|
||||
return request.catch((error) => {
|
||||
toast.error("Failed to save metadata language exceptions");
|
||||
throw error;
|
||||
});
|
||||
};
|
||||
|
||||
async function resetNextUpMode() {
|
||||
// Nothing stored is the state a reset asks for, which the shared reset
|
||||
// already treats as success.
|
||||
@@ -244,31 +261,17 @@ export default function PlaybackSettings() {
|
||||
)}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
label="Metadata language"
|
||||
description="Show titles and descriptions in this language when available. Descriptions can be translated automatically when the server has AI translation enabled."
|
||||
control={(id) => (
|
||||
<div className="w-full">
|
||||
<Select
|
||||
value={metadataLanguage ?? "none"}
|
||||
onValueChange={(value) =>
|
||||
saveValue(SETTING_KEYS.CATALOG_METADATA_LANGUAGE, value === "none" ? null : value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger id={id} className="w-full sm:w-[220px]" disabled={pending}>
|
||||
<SelectValue placeholder="Library default" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">Library default</SelectItem>
|
||||
{metadataLanguageOptions.map((language) => (
|
||||
<SelectItem key={language.value} value={language.value}>
|
||||
{language.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<MetadataLanguageSetting
|
||||
fallback={metadataLanguage}
|
||||
overrides={normalizeMetadataLanguageOverrides(
|
||||
read<unknown>(SETTING_KEYS.CATALOG_METADATA_LANGUAGE_OVERRIDES),
|
||||
)}
|
||||
languageOptions={metadataLanguageOptions}
|
||||
disabled={pending}
|
||||
onFallbackChange={(language) =>
|
||||
saveValue(SETTING_KEYS.CATALOG_METADATA_LANGUAGE, language)
|
||||
}
|
||||
onOverridesChange={saveMetadataOverrides}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { EffectiveSettingsMap, SettingIdentity } from "@/hooks/queries/settingValues";
|
||||
import { getLanguageName } from "@/lib/languageNames";
|
||||
import { SETTING_KEYS, type SettingKey } from "@/lib/settingsContract";
|
||||
import { optionsFor } from "@/lib/settingsDisplay";
|
||||
import { SETTING_DEFINITIONS } from "@/lib/settingsContract";
|
||||
import { getLanguageName } from "@/player/utils/languageNames";
|
||||
|
||||
export const INHERIT_VALUE = "inherit";
|
||||
export const NONE_VALUE = "none";
|
||||
|
||||
@@ -1,173 +1,46 @@
|
||||
/** ISO 639-1 (2-letter) language codes → full display name. */
|
||||
const LANGUAGE_NAMES_2: Record<string, string> = {
|
||||
en: "English",
|
||||
es: "Spanish",
|
||||
fr: "French",
|
||||
de: "German",
|
||||
it: "Italian",
|
||||
pt: "Portuguese",
|
||||
nl: "Dutch",
|
||||
pl: "Polish",
|
||||
ru: "Russian",
|
||||
zh: "Chinese",
|
||||
ja: "Japanese",
|
||||
ko: "Korean",
|
||||
ar: "Arabic",
|
||||
tr: "Turkish",
|
||||
sv: "Swedish",
|
||||
da: "Danish",
|
||||
no: "Norwegian",
|
||||
fi: "Finnish",
|
||||
hu: "Hungarian",
|
||||
cs: "Czech",
|
||||
ro: "Romanian",
|
||||
he: "Hebrew",
|
||||
th: "Thai",
|
||||
vi: "Vietnamese",
|
||||
el: "Greek",
|
||||
bg: "Bulgarian",
|
||||
hr: "Croatian",
|
||||
sk: "Slovak",
|
||||
sl: "Slovenian",
|
||||
uk: "Ukrainian",
|
||||
id: "Indonesian",
|
||||
ms: "Malay",
|
||||
hi: "Hindi",
|
||||
ta: "Tamil",
|
||||
te: "Telugu",
|
||||
bn: "Bengali",
|
||||
fa: "Persian",
|
||||
};
|
||||
import { getLanguageName } from "@/lib/languageNames";
|
||||
|
||||
/** ISO 639-2/B (3-letter) language codes → full display name. */
|
||||
const LANGUAGE_NAMES_3: Record<string, string> = {
|
||||
eng: "English",
|
||||
spa: "Spanish",
|
||||
fre: "French",
|
||||
fra: "French",
|
||||
ger: "German",
|
||||
deu: "German",
|
||||
ita: "Italian",
|
||||
por: "Portuguese",
|
||||
dut: "Dutch",
|
||||
nld: "Dutch",
|
||||
pol: "Polish",
|
||||
rus: "Russian",
|
||||
chi: "Chinese",
|
||||
zho: "Chinese",
|
||||
jpn: "Japanese",
|
||||
kor: "Korean",
|
||||
ara: "Arabic",
|
||||
tur: "Turkish",
|
||||
swe: "Swedish",
|
||||
dan: "Danish",
|
||||
nor: "Norwegian",
|
||||
fin: "Finnish",
|
||||
hun: "Hungarian",
|
||||
cze: "Czech",
|
||||
ces: "Czech",
|
||||
rum: "Romanian",
|
||||
ron: "Romanian",
|
||||
heb: "Hebrew",
|
||||
tha: "Thai",
|
||||
vie: "Vietnamese",
|
||||
gre: "Greek",
|
||||
ell: "Greek",
|
||||
bul: "Bulgarian",
|
||||
hrv: "Croatian",
|
||||
slo: "Slovak",
|
||||
slk: "Slovak",
|
||||
slv: "Slovenian",
|
||||
ukr: "Ukrainian",
|
||||
ind: "Indonesian",
|
||||
may: "Malay",
|
||||
msa: "Malay",
|
||||
hin: "Hindi",
|
||||
tam: "Tamil",
|
||||
tel: "Telugu",
|
||||
ben: "Bengali",
|
||||
per: "Persian",
|
||||
fas: "Persian",
|
||||
};
|
||||
export { getLanguageName, normalizeLanguageCode } from "@/lib/languageNames";
|
||||
|
||||
const LANGUAGE_CODE_ALIASES: Record<string, string> = {
|
||||
eng: "en",
|
||||
spa: "es",
|
||||
fre: "fr",
|
||||
fra: "fr",
|
||||
ger: "de",
|
||||
deu: "de",
|
||||
ita: "it",
|
||||
por: "pt",
|
||||
dut: "nl",
|
||||
nld: "nl",
|
||||
pol: "pl",
|
||||
rus: "ru",
|
||||
chi: "zh",
|
||||
zho: "zh",
|
||||
jpn: "ja",
|
||||
kor: "ko",
|
||||
ara: "ar",
|
||||
tur: "tr",
|
||||
swe: "sv",
|
||||
dan: "da",
|
||||
nor: "no",
|
||||
fin: "fi",
|
||||
hun: "hu",
|
||||
cze: "cs",
|
||||
ces: "cs",
|
||||
rum: "ro",
|
||||
ron: "ro",
|
||||
heb: "he",
|
||||
tha: "th",
|
||||
vie: "vi",
|
||||
gre: "el",
|
||||
ell: "el",
|
||||
bul: "bg",
|
||||
hrv: "hr",
|
||||
slo: "sk",
|
||||
slk: "sk",
|
||||
slv: "sl",
|
||||
ukr: "uk",
|
||||
ind: "id",
|
||||
may: "ms",
|
||||
msa: "ms",
|
||||
hin: "hi",
|
||||
tam: "ta",
|
||||
tel: "te",
|
||||
ben: "bn",
|
||||
per: "fa",
|
||||
fas: "fa",
|
||||
};
|
||||
|
||||
/** Combined lookup: supports both 2-letter and 3-letter codes. */
|
||||
const LANGUAGE_NAMES: Record<string, string> = {
|
||||
...LANGUAGE_NAMES_2,
|
||||
...LANGUAGE_NAMES_3,
|
||||
};
|
||||
|
||||
export function normalizeLanguageCode(code: string | null | undefined): string {
|
||||
const normalized = (code ?? "").trim().toLowerCase();
|
||||
if (!normalized) return "";
|
||||
const separator = normalized.search(/[-_]/);
|
||||
const base = separator >= 0 ? normalized.slice(0, separator) : normalized;
|
||||
return LANGUAGE_CODE_ALIASES[base] ?? base;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the full language name for an ISO 639-1 or 639-2 code.
|
||||
* Falls back to the code itself (uppercased first letter) if unknown.
|
||||
*/
|
||||
export function getLanguageName(code: string): string {
|
||||
if (!code) return "Unknown";
|
||||
const lower = code.toLowerCase();
|
||||
const normalized = normalizeLanguageCode(code);
|
||||
return (
|
||||
LANGUAGE_NAMES_2[normalized] ??
|
||||
LANGUAGE_NAMES[lower] ??
|
||||
code.charAt(0).toUpperCase() + code.slice(1)
|
||||
);
|
||||
}
|
||||
const COMMON_LANGUAGE_CODES = [
|
||||
"en",
|
||||
"es",
|
||||
"fr",
|
||||
"de",
|
||||
"it",
|
||||
"pt",
|
||||
"nl",
|
||||
"pl",
|
||||
"ru",
|
||||
"zh",
|
||||
"ja",
|
||||
"ko",
|
||||
"ar",
|
||||
"tr",
|
||||
"sv",
|
||||
"da",
|
||||
"no",
|
||||
"fi",
|
||||
"hu",
|
||||
"cs",
|
||||
"ro",
|
||||
"he",
|
||||
"th",
|
||||
"vi",
|
||||
"el",
|
||||
"bg",
|
||||
"hr",
|
||||
"sk",
|
||||
"sl",
|
||||
"uk",
|
||||
"id",
|
||||
"ms",
|
||||
"hi",
|
||||
"ta",
|
||||
"te",
|
||||
"bn",
|
||||
"fa",
|
||||
] as const;
|
||||
|
||||
/** Language option for dropdowns (search modal, etc). */
|
||||
export interface LanguageOption {
|
||||
@@ -175,7 +48,8 @@ export interface LanguageOption {
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** Sorted list of languages for UI dropdowns (uses 2-letter codes for API compatibility). */
|
||||
export const LANGUAGES: LanguageOption[] = Object.entries(LANGUAGE_NAMES_2)
|
||||
.map(([code, label]) => ({ code, label }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label));
|
||||
/** Sorted common-language list; labels come from the shared English CLDR resolver. */
|
||||
export const LANGUAGES: LanguageOption[] = COMMON_LANGUAGE_CODES.map((code) => ({
|
||||
code,
|
||||
label: getLanguageName(code),
|
||||
})).sort((a, b) => a.label.localeCompare(b.label));
|
||||
|
||||
@@ -36,8 +36,8 @@ describe("getLanguageName", () => {
|
||||
expect(getLanguageName("ENG")).toBe("English");
|
||||
});
|
||||
|
||||
it("falls back to capitalized code for unknown", () => {
|
||||
expect(getLanguageName("xx")).toBe("Xx");
|
||||
it("labels an unassigned code explicitly", () => {
|
||||
expect(getLanguageName("xx")).toBe("Unknown language (xx)");
|
||||
});
|
||||
|
||||
it("returns 'Unknown' for empty string", () => {
|
||||
|
||||
Reference in New Issue
Block a user