mirror of
https://github.com/wekan/wekan.git
synced 2026-11-05 09:40:20 -05:00
Resolve ordered browser languages when no profile language is saved
Match all browser preferences using existing locale identifiers and explicit standard-to-legacy aliases, including Veps, Venetian, Latin American Spanish and Uzbek scripts. Prefer supported saved profile choices and fall back to English. Respond to browser languagechange without writing a profile choice. Checks cover all 245 locales and alias/error cases. Browser regression added and syntax-checked; execution pending. Thanks to xet7 !
This commit is contained in:
1 parent
0c0adb48b9
commit
1666a2012b
4 files changed
+85
-25
No files matched your search
+11
-25
@@ -2,6 +2,7 @@ import { Meteor } from 'meteor/meteor';
|
||||
import { Tracker } from 'meteor/tracker';
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { TAPi18n } from '/imports/i18n';
|
||||
const { preferredLanguage } = require('/imports/i18n/browserLanguage');
|
||||
|
||||
// We save the user language preference in the user profile, and use that to set
|
||||
// the language reactively. If the user is not connected we use the language
|
||||
@@ -37,30 +38,15 @@ Meteor.startup(() => {
|
||||
// A stored login token can begin resuming after startup has run. Keep this
|
||||
// reactive so the persisted profile language wins when that user arrives,
|
||||
// rather than leaving a resumed session in the browser's language.
|
||||
Tracker.autorun(() => {
|
||||
const applyPreferredLanguage = () => {
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
const [language] = [
|
||||
currentUser?.profile?.language,
|
||||
navigator.languages?.at(0),
|
||||
navigator.language,
|
||||
navigator.userLanguage,
|
||||
].filter(Boolean);
|
||||
if (!language) return;
|
||||
|
||||
// Match the browser tag, then progressively strip trailing subtags until a supported
|
||||
// language is found: e.g. 'zh-Hans-CN' -> 'zh-Hans', 'zh-Hant-TW' -> 'zh-Hant',
|
||||
// 'ja-JP' -> 'ja-JP', bare 'zh' -> 'zh-Hans' (via alias). isLanguageSupported is
|
||||
// case-insensitive, so 'zh-hant' / 'JA-JP' etc. also match.
|
||||
let candidate = language;
|
||||
while (candidate) {
|
||||
if (TAPi18n.isLanguageSupported(candidate)) {
|
||||
Promise.resolve(TAPi18n.setLanguage(candidate)).catch(error => {
|
||||
console.error(`Could not load language ${candidate}:`, error);
|
||||
});
|
||||
break;
|
||||
}
|
||||
const cut = candidate.lastIndexOf('-');
|
||||
candidate = cut > 0 ? candidate.slice(0, cut) : '';
|
||||
}
|
||||
});
|
||||
const language = preferredLanguage(currentUser?.profile?.language, navigator,
|
||||
candidate => TAPi18n.resolveTag(candidate));
|
||||
Promise.resolve(TAPi18n.setLanguage(language)).catch(error => {
|
||||
console.error(`Could not load language ${language}:`, error);
|
||||
});
|
||||
};
|
||||
Tracker.autorun(applyPreferredLanguage);
|
||||
// Browsers can change their ordered preferences without a page reload.
|
||||
window.addEventListener('languagechange', applyPreferredLanguage);
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
// Browser BCP 47 tags mapped to WeKan's historical locale identifiers.
|
||||
const aliases = {
|
||||
vep: 've-PP', vec: 've-CC', vls: 'vl-SS', war: 'wa-RR',
|
||||
'es-419': 'es-LA', 'be-by': 'be-BE', 'ca-valencia': 'ca-valencia',
|
||||
'uz-arab': 'uz-AR', 'uz-latn': 'uz-LA', iw: 'he', ji: 'yi', in: 'id',
|
||||
no: 'nb',
|
||||
};
|
||||
function matchBrowserLanguage(value, resolveTag) {
|
||||
if (typeof value !== 'string' || !value.trim()) return undefined;
|
||||
const original = resolveTag(value.trim());
|
||||
if (original) return original;
|
||||
let candidate = value.trim().replace(/_/g, '-');
|
||||
while (candidate) {
|
||||
const exact = resolveTag(candidate);
|
||||
if (exact) return exact;
|
||||
const mapped = aliases[candidate.toLowerCase()];
|
||||
if (mapped && resolveTag(mapped)) return resolveTag(mapped);
|
||||
const cut = candidate.lastIndexOf('-');
|
||||
candidate = cut > 0 ? candidate.slice(0, cut) : '';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
function preferredLanguage(profileLanguage, navigatorInfo, resolveTag) {
|
||||
const saved = matchBrowserLanguage(profileLanguage, resolveTag);
|
||||
if (saved) return saved;
|
||||
const browserLanguages = Array.isArray(navigatorInfo.languages) ? navigatorInfo.languages : [];
|
||||
for (const language of [...browserLanguages, navigatorInfo.language, navigatorInfo.userLanguage]) {
|
||||
const supported = matchBrowserLanguage(language, resolveTag);
|
||||
if (supported) return supported;
|
||||
}
|
||||
return resolveTag('en') || 'en';
|
||||
}
|
||||
module.exports = { matchBrowserLanguage, preferredLanguage };
|
||||
@@ -0,0 +1,26 @@
|
||||
'use strict';
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { parseLanguageMetadata } = require('./lib/languageRegistrySource.cjs');
|
||||
const { matchBrowserLanguage, preferredLanguage } = require('../imports/i18n/browserLanguage');
|
||||
const rows = parseLanguageMetadata(fs.readFileSync(path.join(__dirname, '../imports/i18n/languages.js'), 'utf8'));
|
||||
const normalize = tag => tag.toLowerCase().replace(/_/g, '-');
|
||||
const resolve = value => rows.find(row => row[2] === value)?.[2] || rows.find(row => normalize(row[2]) === normalize(value))?.[2];
|
||||
for (const row of rows) {
|
||||
assert.equal(matchBrowserLanguage(row[2], resolve), row[2]);
|
||||
const browserTag = row[2].replace(/_/g, '-');
|
||||
assert.equal(normalize(matchBrowserLanguage(browserTag.toLowerCase(), resolve)), normalize(row[2]));
|
||||
}
|
||||
for (const [browser, expected] of Object.entries({vep:'ve-PP', vec:'ve-CC', vls:'vl-SS', war:'wa-RR', 'es-419':'es-LA', 'be-BY':'be-BE', 'uz-Arab-UZ':'uz-AR', 'uz-Latn-UZ':'uz-LA', 'zh-Hant-TW':'zh-Hant', 'fr-CA-u-ca-gregory':'fr-CA', 'en-AU':'en_AU', iw:'he', in:'id', no:'nb'})) {
|
||||
assert.equal(matchBrowserLanguage(browser, resolve), expected, browser);
|
||||
}
|
||||
assert.equal(preferredLanguage(undefined, {languages:['zz-ZZ','fr-CA','de']}, resolve), 'fr-CA');
|
||||
assert.equal(preferredLanguage('', {language:'es-CO'}, resolve), 'es-CO');
|
||||
assert.equal(preferredLanguage('de', {languages:['fr-CA']}, resolve), 'de');
|
||||
assert.equal(preferredLanguage('unsupported', {languages:['fr-CA']}, resolve), 'fr-CA');
|
||||
assert.equal(preferredLanguage(undefined, {languages:['unsupported']}, resolve), 'en');
|
||||
assert.equal(preferredLanguage(undefined, {userLanguage:'fi-FI'}, resolve), 'fi');
|
||||
assert.equal(matchBrowserLanguage(null, resolve), undefined);
|
||||
assert.notEqual(matchBrowserLanguage('vep', resolve), 've');
|
||||
console.log(`browserLanguageFallback: ${rows.length} registry locales, standard aliases, ordered preferences and saved-profile precedence passed`);
|
||||
@@ -20,3 +20,18 @@ test('language popup lists every locale and orders country before language flags
|
||||
await expect(entry.locator('.language-flags')).toHaveAttribute('dir','ltr');
|
||||
}
|
||||
});
|
||||
|
||||
test('missing profile language follows browser preferences; saved choice wins', async ({ boardPage, user }) => {
|
||||
const db = require('../helpers/db');
|
||||
db.updateOne('users', {_id:user._id}, {$unset:{'profile.language':''}});
|
||||
await boardPage.addInitScript(() => {
|
||||
Object.defineProperty(navigator, 'languages', {configurable:true, get:() => ['zz-ZZ', 'vep']});
|
||||
Object.defineProperty(navigator, 'language', {configurable:true, get:() => 'zz-ZZ'});
|
||||
});
|
||||
await boardPage.reload();
|
||||
await expect(boardPage.locator('html')).toHaveAttribute('lang','ve-PP');
|
||||
expect(db.findOne('users',{_id:user._id}).profile?.language).toBeUndefined();
|
||||
db.updateOne('users', {_id:user._id}, {$set:{'profile.language':'de'}});
|
||||
await boardPage.reload();
|
||||
await expect(boardPage.locator('html')).toHaveAttribute('lang','de');
|
||||
});
|
||||
Reference in new issue
Block a user