Use LocaleMatcher to resolve system preferred locales
This commit is contained in:
parent
68ae25f5cd
commit
cdc68d1c34
9 changed files with 224 additions and 48 deletions
|
@ -4,21 +4,13 @@
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { readFileSync } from 'fs';
|
import { readFileSync } from 'fs';
|
||||||
import { merge } from 'lodash';
|
import { merge } from 'lodash';
|
||||||
|
import * as LocaleMatcher from '@formatjs/intl-localematcher';
|
||||||
import { setupI18n } from '../ts/util/setupI18n';
|
import { setupI18n } from '../ts/util/setupI18n';
|
||||||
|
|
||||||
import type { LoggerType } from '../ts/types/Logging';
|
import type { LoggerType } from '../ts/types/Logging';
|
||||||
import type { LocaleMessagesType } from '../ts/types/I18N';
|
import type { LocaleMessagesType } from '../ts/types/I18N';
|
||||||
import type { LocalizerType } from '../ts/types/Util';
|
import type { LocalizerType } from '../ts/types/Util';
|
||||||
|
|
||||||
function removeRegion(locale: string): string {
|
|
||||||
const match = /^([^-]+)(-.+)$/.exec(locale);
|
|
||||||
if (match) {
|
|
||||||
return match[1];
|
|
||||||
}
|
|
||||||
|
|
||||||
return locale;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLocaleMessages(locale: string): LocaleMessagesType {
|
function getLocaleMessages(locale: string): LocaleMessagesType {
|
||||||
const targetFile = join(__dirname, '..', '_locales', locale, 'messages.json');
|
const targetFile = join(__dirname, '..', '_locales', locale, 'messages.json');
|
||||||
|
|
||||||
|
@ -53,47 +45,43 @@ export function load({
|
||||||
logger,
|
logger,
|
||||||
}: {
|
}: {
|
||||||
preferredSystemLocales: Array<string>;
|
preferredSystemLocales: Array<string>;
|
||||||
logger: Pick<LoggerType, 'error' | 'warn' | 'info'>;
|
logger: Pick<LoggerType, 'warn' | 'info'>;
|
||||||
}): LocaleType {
|
}): LocaleType {
|
||||||
if (preferredSystemLocales == null) {
|
if (preferredSystemLocales == null) {
|
||||||
throw new TypeError('`preferredSystemLocales` is required');
|
throw new TypeError('locale: `preferredSystemLocales` is required');
|
||||||
}
|
}
|
||||||
|
if (!logger.info) {
|
||||||
if (!logger || !logger.error) {
|
throw new TypeError('locale: `logger.info` is required');
|
||||||
throw new TypeError('`logger.error` is required');
|
|
||||||
}
|
}
|
||||||
if (!logger.warn) {
|
if (!logger.warn) {
|
||||||
throw new TypeError('`logger.warn` is required');
|
throw new TypeError('locale: `logger.warn` is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (preferredSystemLocales.length === 0) {
|
if (preferredSystemLocales.length === 0) {
|
||||||
logger.warn('`preferredSystemLocales` was empty');
|
logger.warn('locale: `preferredSystemLocales` was empty');
|
||||||
}
|
}
|
||||||
|
|
||||||
const english = getLocaleMessages('en');
|
const availableLocales = JSON.parse(
|
||||||
|
readFileSync(
|
||||||
|
join(__dirname, '..', 'build', 'available-locales.json'),
|
||||||
|
'utf-8'
|
||||||
|
)
|
||||||
|
) as Array<string>;
|
||||||
|
|
||||||
for (const locale of preferredSystemLocales) {
|
logger.info('locale: Supported locales:', availableLocales.join(', '));
|
||||||
try {
|
logger.info('locale: Preferred locales: ', preferredSystemLocales.join(', '));
|
||||||
logger.info(`Loading preferred system locale: '${locale}'`);
|
|
||||||
return finalize(getLocaleMessages(locale), english, locale);
|
|
||||||
} catch (e) {
|
|
||||||
logger.warn(
|
|
||||||
`Problem loading messages for locale '${locale}', ${e.toString()}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const languageOnly = removeRegion(locale);
|
const matchedLocale = LocaleMatcher.match(
|
||||||
try {
|
preferredSystemLocales,
|
||||||
logger.warn(`Falling back to parent language: '${languageOnly}'`);
|
availableLocales,
|
||||||
// Note: messages are from parent language, but we still keep the region
|
'en',
|
||||||
return finalize(getLocaleMessages(languageOnly), english, locale);
|
{ algorithm: 'best fit' }
|
||||||
} catch (e) {
|
);
|
||||||
logger.error(
|
|
||||||
`Problem loading messages for parent locale '${languageOnly}'`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.warn("Falling back to 'en' locale");
|
logger.info(`locale: Matched locale: ${matchedLocale}`);
|
||||||
return finalize(english, english, 'en');
|
|
||||||
|
const matchedLocaleMessages = getLocaleMessages(matchedLocale);
|
||||||
|
const englishMessages = getLocaleMessages('en');
|
||||||
|
|
||||||
|
return finalize(matchedLocaleMessages, englishMessages, matchedLocale);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1563,11 +1563,7 @@ ipc.on('database-readonly', (_event: Electron.Event, error: string) => {
|
||||||
function loadPreferredSystemLocales(): Array<string> {
|
function loadPreferredSystemLocales(): Array<string> {
|
||||||
return getEnvironment() === Environment.Test
|
return getEnvironment() === Environment.Test
|
||||||
? ['en']
|
? ['en']
|
||||||
: [
|
: app.getPreferredSystemLanguages();
|
||||||
// TODO(DESKTOP-4929): Temp fix to inherit Chromium's l10n_util logic
|
|
||||||
app.getLocale(),
|
|
||||||
...app.getPreferredSystemLanguages(),
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getDefaultLoginItemSettings(): Promise<LoginItemSettingsOptions> {
|
async function getDefaultLoginItemSettings(): Promise<LoginItemSettingsOptions> {
|
||||||
|
|
71
build/available-locales.json
Normal file
71
build/available-locales.json
Normal file
|
@ -0,0 +1,71 @@
|
||||||
|
[
|
||||||
|
"af-ZA",
|
||||||
|
"ar",
|
||||||
|
"az-AZ",
|
||||||
|
"bg-BG",
|
||||||
|
"bn-BD",
|
||||||
|
"bs-BA",
|
||||||
|
"ca",
|
||||||
|
"cs",
|
||||||
|
"da",
|
||||||
|
"de",
|
||||||
|
"el",
|
||||||
|
"en",
|
||||||
|
"es",
|
||||||
|
"et-EE",
|
||||||
|
"eu",
|
||||||
|
"fa-IR",
|
||||||
|
"fi",
|
||||||
|
"fr",
|
||||||
|
"ga-IE",
|
||||||
|
"gl-ES",
|
||||||
|
"gu-IN",
|
||||||
|
"he",
|
||||||
|
"hi-IN",
|
||||||
|
"hr-HR",
|
||||||
|
"hu",
|
||||||
|
"id",
|
||||||
|
"it",
|
||||||
|
"ja",
|
||||||
|
"ka-GE",
|
||||||
|
"kk-KZ",
|
||||||
|
"km-KH",
|
||||||
|
"kn-IN",
|
||||||
|
"ko",
|
||||||
|
"ky-KG",
|
||||||
|
"lt-LT",
|
||||||
|
"lv-LV",
|
||||||
|
"mk-MK",
|
||||||
|
"ml-IN",
|
||||||
|
"mr-IN",
|
||||||
|
"ms",
|
||||||
|
"my",
|
||||||
|
"nb",
|
||||||
|
"nl",
|
||||||
|
"pa-IN",
|
||||||
|
"pl",
|
||||||
|
"pt-BR",
|
||||||
|
"pt-PT",
|
||||||
|
"ro-RO",
|
||||||
|
"ru",
|
||||||
|
"sk-SK",
|
||||||
|
"sl-SI",
|
||||||
|
"sq-AL",
|
||||||
|
"sr-RS",
|
||||||
|
"sr-YR",
|
||||||
|
"sv",
|
||||||
|
"sw",
|
||||||
|
"ta-IN",
|
||||||
|
"te-IN",
|
||||||
|
"th",
|
||||||
|
"tl-PH",
|
||||||
|
"tr",
|
||||||
|
"ug",
|
||||||
|
"uk-UA",
|
||||||
|
"ur",
|
||||||
|
"vi",
|
||||||
|
"yue",
|
||||||
|
"zh-CN",
|
||||||
|
"zh-HK",
|
||||||
|
"zh-TW"
|
||||||
|
]
|
|
@ -20,7 +20,7 @@
|
||||||
"build-release": "yarn run build",
|
"build-release": "yarn run build",
|
||||||
"sign-release": "node ts/updater/generateSignature.js",
|
"sign-release": "node ts/updater/generateSignature.js",
|
||||||
"notarize": "echo 'No longer necessary'",
|
"notarize": "echo 'No longer necessary'",
|
||||||
"get-strings": "node ts/scripts/get-strings.js && node ts/scripts/gen-nsis-script.js",
|
"get-strings": "node ts/scripts/get-strings.js && node ts/scripts/gen-nsis-script.js && node ts/scripts/gen-locales-config.js",
|
||||||
"push-strings": "node ts/scripts/remove-strings.js && node ts/scripts/push-strings.js",
|
"push-strings": "node ts/scripts/remove-strings.js && node ts/scripts/push-strings.js",
|
||||||
"get-expire-time": "node ts/scripts/get-expire-time.js",
|
"get-expire-time": "node ts/scripts/get-expire-time.js",
|
||||||
"copy-components": "node ts/scripts/copy.js",
|
"copy-components": "node ts/scripts/copy.js",
|
||||||
|
@ -458,6 +458,7 @@
|
||||||
"fonts/**",
|
"fonts/**",
|
||||||
"sounds/*",
|
"sounds/*",
|
||||||
"build/icons",
|
"build/icons",
|
||||||
|
"build/available-locales.json",
|
||||||
"node_modules/**",
|
"node_modules/**",
|
||||||
"!node_modules/underscore/**",
|
"!node_modules/underscore/**",
|
||||||
"!node_modules/emoji-datasource/emoji_pretty.json",
|
"!node_modules/emoji-datasource/emoji_pretty.json",
|
||||||
|
|
62
ts/scripts/gen-locales-config.ts
Normal file
62
ts/scripts/gen-locales-config.ts
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
// Copyright 2023 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import fs from 'fs/promises';
|
||||||
|
import path from 'path';
|
||||||
|
import fastGlob from 'fast-glob';
|
||||||
|
import * as LocaleMatcher from '@formatjs/intl-localematcher';
|
||||||
|
|
||||||
|
const ROOT_DIR = path.join(__dirname, '..', '..');
|
||||||
|
|
||||||
|
function matches(input: string, expected: string) {
|
||||||
|
const match = LocaleMatcher.match([input], [expected], 'en', {
|
||||||
|
algorithm: 'best fit',
|
||||||
|
});
|
||||||
|
return match === expected;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const dirEntries = await fastGlob('_locales/*', {
|
||||||
|
cwd: ROOT_DIR,
|
||||||
|
onlyDirectories: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const localeDirNames = [];
|
||||||
|
|
||||||
|
for (const dirEntry of dirEntries) {
|
||||||
|
const dirName = path.basename(dirEntry);
|
||||||
|
const locale = new Intl.Locale(dirName);
|
||||||
|
|
||||||
|
// Smartling doesn't always use the correct language tag, so this check and
|
||||||
|
// reverse check are to make sure we don't accidentally add a locale that
|
||||||
|
// doesn't match its directory name (using LocaleMatcher).
|
||||||
|
//
|
||||||
|
// If this check ever fails, we may need to update our get-strings script to
|
||||||
|
// manually rename language tags before writing them to disk.
|
||||||
|
//
|
||||||
|
// Such is the case for Smartling's "zh-YU" locale, which we renamed to
|
||||||
|
// "yue" to match the language tag used by... everyone else.
|
||||||
|
|
||||||
|
if (!matches(dirName, locale.baseName)) {
|
||||||
|
throw new Error(
|
||||||
|
`Matched locale "${dirName}" does not match its resolved name "${locale.baseName}"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!matches(locale.baseName, dirName)) {
|
||||||
|
throw new Error(
|
||||||
|
`Matched locale "${dirName}" does not match its dir name "${dirName}"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
localeDirNames.push(dirName);
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonPath = path.join(ROOT_DIR, 'build', 'available-locales.json');
|
||||||
|
console.log(`Writing to "${jsonPath}"...`);
|
||||||
|
await fs.writeFile(jsonPath, `${JSON.stringify(localeDirNames, null, 2)}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(error => {
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
|
@ -2,6 +2,8 @@
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import { execSync } from 'child_process';
|
import { execSync } from 'child_process';
|
||||||
|
import fsExtra from 'fs-extra';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
const { SMARTLING_USER, SMARTLING_SECRET } = process.env;
|
const { SMARTLING_USER, SMARTLING_SECRET } = process.env;
|
||||||
|
|
||||||
|
@ -29,6 +31,19 @@ execSync(
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
function rename(from: string, to: string) {
|
||||||
|
console.log(`Renaming "${from}" to "${to}"`);
|
||||||
|
fsExtra.moveSync(path.join('_locales', from), path.join('_locales', to), {
|
||||||
|
overwrite: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Smartling uses "zh-YU" for Cantonese (or Yue Chinese).
|
||||||
|
// This is wrong.
|
||||||
|
// The language tag for Yue Chinese is "yue"
|
||||||
|
// "zh-YU" actually implies "Chinese as spoken in Yugoslavia (canonicalized to Serbia)"
|
||||||
|
rename('zh-YU', 'yue');
|
||||||
|
|
||||||
console.log('Formatting newly-downloaded strings!');
|
console.log('Formatting newly-downloaded strings!');
|
||||||
console.log();
|
console.log();
|
||||||
execSync('yarn format', {
|
execSync('yarn format', {
|
||||||
|
|
46
ts/test-node/app/locale_test.ts
Normal file
46
ts/test-node/app/locale_test.ts
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
// Copyright 2021 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import { assert } from 'chai';
|
||||||
|
import { load } from '../../../app/locale';
|
||||||
|
import type { LoggerType } from '../../types/Logging';
|
||||||
|
|
||||||
|
const logger: Pick<LoggerType, 'info' | 'warn'> = {
|
||||||
|
info(..._args: Array<unknown>) {
|
||||||
|
// noop
|
||||||
|
},
|
||||||
|
warn(..._args: Array<unknown>) {
|
||||||
|
throw new Error(String(_args));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('locale', async () => {
|
||||||
|
describe('load', () => {
|
||||||
|
it('resolves expected locales correctly', async () => {
|
||||||
|
async function testCase(
|
||||||
|
preferredSystemLocales: Array<string>,
|
||||||
|
expectedLocale: string
|
||||||
|
) {
|
||||||
|
const actualLocale = await load({ preferredSystemLocales, logger });
|
||||||
|
assert.strictEqual(actualLocale.name, expectedLocale);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic tests
|
||||||
|
await testCase(['en'], 'en');
|
||||||
|
await testCase(['es'], 'es');
|
||||||
|
await testCase(['fr', 'hk'], 'fr');
|
||||||
|
await testCase(['fr-FR', 'hk'], 'fr');
|
||||||
|
await testCase(['fa-UK'], 'fa-IR');
|
||||||
|
await testCase(['an', 'fr-FR'], 'fr'); // If we ever add support for Aragonese, this test will fail.
|
||||||
|
|
||||||
|
// Specific cases we want to ensure work as expected
|
||||||
|
await testCase(['zh-Hant-TW'], 'zh-TW');
|
||||||
|
await testCase(['zh-Hant-HK'], 'zh-HK');
|
||||||
|
await testCase(['zh'], 'zh-CN');
|
||||||
|
await testCase(['yue'], 'yue');
|
||||||
|
await testCase(['ug'], 'ug');
|
||||||
|
await testCase(['nn', 'nb'], 'nb');
|
||||||
|
await testCase(['es-419'], 'es');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -202,9 +202,6 @@ describe('createTemplate', () => {
|
||||||
info(_arg: unknown) {
|
info(_arg: unknown) {
|
||||||
// noop
|
// noop
|
||||||
},
|
},
|
||||||
error(arg: unknown) {
|
|
||||||
throw new Error(String(arg));
|
|
||||||
},
|
|
||||||
warn(arg: unknown) {
|
warn(arg: unknown) {
|
||||||
throw new Error(String(arg));
|
throw new Error(String(arg));
|
||||||
},
|
},
|
||||||
|
|
Loading…
Reference in a new issue