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:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user