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:
Quick
2026-07-31 15:23:30 -04:00
committed by GitHub
parent d70b291bb8
commit 73488d1bfa
46 changed files with 1572 additions and 437 deletions
+1 -34
View File
@@ -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";
+33
View File
@@ -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)");
});
});
+90
View File
@@ -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;
}
+39 -1
View File
@@ -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" },
]);
});
});
+16 -18
View File
@@ -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;
}
+22 -1
View File
@@ -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.",
+19 -2
View File
@@ -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",