From eaca4d8d6fc0028fda24de577f09b8301f5cb37c Mon Sep 17 00:00:00 2001 From: Jacobinwwey Date: Tue, 14 Apr 2026 08:44:45 -0500 Subject: [PATCH] fix: harden ui locale fallback resolution --- README.md | 1 + docs/releases/1.8.2.md | 2 + src/i18n/index.ts | 4 +- src/i18n/languageContext.ts | 87 ++++++++++++++++++++++++++------ src/tests/i18nFallback.test.ts | 11 ++++ src/tests/languagePolicy.test.ts | 13 +++++ 6 files changed, 100 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 0ec89023..5db94871 100644 --- a/README.md +++ b/README.md @@ -256,6 +256,7 @@ Access plugin settings via: ### Language Architecture (UI Locale vs Task Output Language) - **UI Locale** controls only plugin interface text (Settings labels, sidebar buttons, notices, and dialogs). The default `auto` mode follows Obsidian's current UI language. + - Regional/script variants now resolve to the nearest shipped catalog instead of falling straight back to English. For example, `fr-CA` uses French, `es-419` uses Spanish, `pt-PT` uses Portuguese, `zh-Hans` uses Simplified Chinese, and `zh-Hant-HK` uses Traditional Chinese. - **Task Output Language** controls model-generated task output (links, summaries, title generation, Mermaid summary, concept extraction, translation target). - **Per-task language mode** lets each task resolve its own output language from a unified policy layer instead of scattered per-module overrides. - **Disable auto translation** keeps non-Translate tasks in source-language context, while explicit Translate tasks still enforce the configured target language. diff --git a/docs/releases/1.8.2.md b/docs/releases/1.8.2.md index ecfba9ed..57aefcdd 100644 --- a/docs/releases/1.8.2.md +++ b/docs/releases/1.8.2.md @@ -21,6 +21,7 @@ It also brings the documentation back into line with the code. The remaining tra ### 🐛 Bug Fixes * `fix`: Fixed the 1.8.0/1.8.1 regression where parts of the frontend could still show English after upgrade even when Obsidian UI language had changed. +* `fix`: `uiLocale: auto` now resolves regional/script variants like `fr-CA`, `es-419`, `pt-PT`, `zh-Hans`, and `zh-Hant-HK` onto the nearest shipped locale instead of dropping to English. * `fix`: Closed remaining untranslated labels in advanced language settings, retry/diagnostic controls, concept-note output settings, translation settings, and quick-workflow UI surfaces. * `fix`: Aligned the remaining published README translations with the original English README, including troubleshooting and language-support sections. * `fix`: Removed hardcoded light/dark preview chrome so iframe fallback previews, Mermaid rendering, JSON Canvas rendering, and Vega-Lite export snapshots align with the resolved Obsidian theme more reliably. @@ -59,6 +60,7 @@ Thanks to `@Jacobinwwey` for driving the locale-coverage completion, README alig ### 🐛 Bug 修复 * `fix`: 修复 1.8.0 / 1.8.1 中升级后前端部分文案仍可能停留在英文、未随 Obsidian 语言切换的问题。 +* `fix`: `uiLocale: auto` 现在会将 `fr-CA`、`es-419`、`pt-PT`、`zh-Hans`、`zh-Hant-HK` 等区域 / 书写体系变体映射到最近的已发布 locale,而不是直接回退到英文。 * `fix`: 补齐高级语言设置、重试与诊断控制、概念笔记输出设置、翻译设置、快速工作流等区域残留的未翻译标签。 * `fix`: 将剩余多语 README 与英文原始 README 完整对齐,补齐语言支持与故障排查等章节内容。 * `fix`: 去除预览 iframe、Mermaid、JSON Canvas 与 Vega-Lite 导出中的硬编码明暗外观,使预览与导出快照更稳定地贴合当前 Obsidian 主题。 diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 566c7040..a72addee 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -1,5 +1,5 @@ import { getLanguage } from 'obsidian'; -import { normalizeLocaleCode, resolveUiLocale, UI_LOCALE_AUTO } from './languageContext'; +import { normalizeLocaleCode, resolveSupportedLocaleCode, resolveUiLocale, UI_LOCALE_AUTO } from './languageContext'; import { STRINGS_EN, NotemdEnglishStrings } from './locales/en'; import { STRINGS_ZH_CN } from './locales/zh_cn'; import { STRINGS_ZH_TW } from './locales/zh_tw'; @@ -94,7 +94,7 @@ function getLocaleLayers(locale: string): Array> } export function getResolvedStrings(locale: string): TranslationStrings { - const normalizedLocale = normalizeLocaleCode(locale); + const normalizedLocale = resolveSupportedLocaleCode(locale); if (normalizedLocale === 'en') { return STRINGS_EN; } diff --git a/src/i18n/languageContext.ts b/src/i18n/languageContext.ts index d57deb16..e5f2f946 100644 --- a/src/i18n/languageContext.ts +++ b/src/i18n/languageContext.ts @@ -3,15 +3,39 @@ import { NotemdSettings } from '../types'; export const UI_LOCALE_AUTO = 'auto'; +const CHINESE_TRADITIONAL_SEGMENTS = new Set(['hant', 'tw', 'hk', 'mo']); +const CHINESE_SIMPLIFIED_SEGMENTS = new Set(['hans', 'cn', 'sg', 'my']); + function normalizeChineseLocale(locale: string): string { - const lower = locale.toLowerCase(); - if (lower === 'zh' || lower === 'zh-cn' || lower === 'zh_cn') { + const lower = locale.toLowerCase().replace(/_/g, '-'); + const segments = lower.split('-').filter(Boolean); + + if (segments.length <= 1) { return 'zh-CN'; } - if (lower === 'zh-tw' || lower === 'zh_tw' || lower === 'zh-hant') { + + const variants = segments.slice(1); + if (variants.some(segment => CHINESE_TRADITIONAL_SEGMENTS.has(segment))) { return 'zh-TW'; } - return locale; + if (variants.some(segment => CHINESE_SIMPLIFIED_SEGMENTS.has(segment))) { + return 'zh-CN'; + } + + return 'zh-CN'; +} + +function normalizeLocaleSubtag(subtag: string): string { + if (/^\d+$/.test(subtag)) { + return subtag; + } + if (/^[a-z]{2}$/i.test(subtag)) { + return subtag.toUpperCase(); + } + if (/^[a-z]{4}$/i.test(subtag)) { + return `${subtag[0].toUpperCase()}${subtag.slice(1).toLowerCase()}`; + } + return subtag.toLowerCase(); } export function normalizeLocaleCode(locale: string | undefined | null): string { @@ -24,21 +48,54 @@ export function normalizeLocaleCode(locale: string | undefined | null): string { return 'en'; } - const unified = normalizeChineseLocale(trimmed.replace(/_/g, '-')); - const parts = unified.split('-'); + const unified = trimmed.replace(/_/g, '-'); + const parts = unified.split('-').filter(Boolean); + if (parts.length === 0) { + return 'en'; + } + + if (parts[0].toLowerCase() === 'zh') { + return normalizeChineseLocale(unified); + } + if (parts.length === 1) { return parts[0].toLowerCase(); } - return `${parts[0].toLowerCase()}-${parts[1].toUpperCase()}`; + return [ + parts[0].toLowerCase(), + ...parts.slice(1).map(normalizeLocaleSubtag) + ].join('-'); +} + +export function resolveSupportedLocaleCode( + locale: string | undefined | null, + supportedLocales: readonly string[] = SUPPORTED_UI_LOCALE_CODES +): string { + const normalized = normalizeLocaleCode(locale); + const normalizedSupported = supportedLocales.map(normalizeLocaleCode); + + if (normalizedSupported.includes(normalized)) { + return normalized; + } + + const [baseLanguage] = normalized.split('-'); + if (normalizedSupported.includes(baseLanguage)) { + return baseLanguage; + } + + return 'en'; } export function languageCodesEqual(left: string | undefined | null, right: string | undefined | null): boolean { - return normalizeLocaleCode(left) === normalizeLocaleCode(right); + return resolveSupportedLocaleCode(left) === resolveSupportedLocaleCode(right); } export function resolveLanguageDisplayName(settings: NotemdSettings, languageCode: string): string { - const normalizedTarget = normalizeLocaleCode(languageCode); + const normalizedTarget = resolveSupportedLocaleCode( + languageCode, + settings.availableLanguages.map(language => language.code) + ); const language = settings.availableLanguages.find(lang => languageCodesEqual(lang.code, normalizedTarget)); return language?.name || normalizedTarget; } @@ -48,17 +105,15 @@ export function resolveUiLocale( obsidianLocale: string | undefined | null, supportedLocales: readonly string[] = SUPPORTED_UI_LOCALE_CODES ): string { - const normalizedSupported = supportedLocales.map(normalizeLocaleCode); - const configured = normalizeLocaleCode(settings.uiLocale); + const configured = resolveSupportedLocaleCode(settings.uiLocale, supportedLocales); - if (configured !== UI_LOCALE_AUTO && normalizedSupported.includes(configured)) { + if (settings.uiLocale !== UI_LOCALE_AUTO && configured !== 'en') { return configured; } - const detected = normalizeLocaleCode(obsidianLocale); - if (normalizedSupported.includes(detected)) { - return detected; + if (settings.uiLocale !== UI_LOCALE_AUTO && normalizeLocaleCode(settings.uiLocale) === 'en') { + return 'en'; } - return 'en'; + return resolveSupportedLocaleCode(obsidianLocale, supportedLocales); } diff --git a/src/tests/i18nFallback.test.ts b/src/tests/i18nFallback.test.ts index 60223b52..40a976a9 100644 --- a/src/tests/i18nFallback.test.ts +++ b/src/tests/i18nFallback.test.ts @@ -51,6 +51,17 @@ describe('i18n fallback and cache', () => { expect(resolved.common.cancel).toBe('Abbrechen'); }); + test('falls back from regional Obsidian locales to supported base catalogs', () => { + (getLanguage as jest.Mock).mockReturnValue('fr-CA'); + const resolved = getI18nStrings({ uiLocale: 'auto' }); + expect(resolved.common.cancel).toBe('Annuler'); + }); + + test('maps Chinese script variants onto the correct catalog', () => { + expect(getResolvedStrings('zh-Hans')).toBe(getResolvedStrings('zh-CN')); + expect(getResolvedStrings('zh-Hant-HK')).toBe(getResolvedStrings('zh-TW')); + }); + test('supports manual ui locale override for regional variants', () => { (getLanguage as jest.Mock).mockReturnValue('en'); const resolved = getI18nStrings({ uiLocale: 'pt-BR' }); diff --git a/src/tests/languagePolicy.test.ts b/src/tests/languagePolicy.test.ts index 33af8967..bd616ae7 100644 --- a/src/tests/languagePolicy.test.ts +++ b/src/tests/languagePolicy.test.ts @@ -94,6 +94,19 @@ describe('ui locale resolution', () => { expect(resolveUiLocale(settings, 'fr')).toBe('fr'); }); + test('falls back to supported base locale for regional Obsidian variants', () => { + const settings = createSettings({ uiLocale: 'auto' }); + expect(resolveUiLocale(settings, 'fr-CA')).toBe('fr'); + expect(resolveUiLocale(settings, 'es-419')).toBe('es'); + expect(resolveUiLocale(settings, 'pt-PT')).toBe('pt'); + }); + + test('maps Chinese script and region variants onto supported zh locales', () => { + const settings = createSettings({ uiLocale: 'auto' }); + expect(resolveUiLocale(settings, 'zh-Hans')).toBe('zh-CN'); + expect(resolveUiLocale(settings, 'zh-Hant-HK')).toBe('zh-TW'); + }); + test('falls back to en when unsupported', () => { const settings = createSettings({ uiLocale: 'auto' }); expect(resolveUiLocale(settings, 'xx')).toBe('en');