fix: harden ui locale fallback resolution

This commit is contained in:
Jacobinwwey 2026-04-14 08:44:45 -05:00
parent ed44caa847
commit eaca4d8d6f
6 changed files with 100 additions and 18 deletions

View file

@ -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.

View file

@ -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 主题。

View file

@ -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<DeepPartial<TranslationStrings>>
}
export function getResolvedStrings(locale: string): TranslationStrings {
const normalizedLocale = normalizeLocaleCode(locale);
const normalizedLocale = resolveSupportedLocaleCode(locale);
if (normalizedLocale === 'en') {
return STRINGS_EN;
}

View file

@ -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);
}

View file

@ -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' });

View file

@ -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');