Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ Translations happen on translate.wordpress.org, in the project `meta/contributor
- **Out:** every push to trunk that touches `src/` runs [`i18n-pot.yml`](.github/workflows/i18n-pot.yml), which regenerates the `.pot` and commits it to the `translations` branch. translate.wordpress.org imports its strings from <https://raw.githubusercontent.com/WordPress/contributor-toolkit/translations/contributor-toolkit.pot>. Nothing to do by hand; run `npm run i18n:pot` from the repository root to see the same file locally, at `contributor-toolkit.pot` in the repository root.
- **Back:** in the version-bump pull request for a release, run `npm run i18n:download` from the repository root. It writes a catalog to `src/languages/` for every locale at least 80% translated, removes the catalog of any locale that fell below that, and prints a table; commit `src/languages/` and paste the table into the pull request. To include strings awaiting approval, run `npm run i18n:download -- --status=current,waiting` from the repository root; approved and waiting strings count toward the same 80% cutoff and appear in the exported catalogs. Use `--status=waiting` for waiting strings only, or include `fuzzy` in the comma-separated status list. Add `--locales=de,fr` to limit downloads and removals to those locale slugs, leaving other catalogs untouched. Explicitly specifying `--status` or `--locales` (`--locale` is accepted as an alias) bypasses the 80% cutoff; locale eligibility rules still apply. Unknown flags are rejected before any catalogs change. To choose another project, pass its full path, for example `--project=meta/contributor-toolkit` (the default). Until translate.wordpress.org has the project, the script says so and changes nothing, and the release ships in English.

The app picks the catalog for the operating system's language. Right-to-left locales such as Arabic, Hebrew and Persian pass the cut-off and still do not ship, and the table names them: the page already follows a catalog's text direction, but the app's styles are not right-to-left yet, so those locales wait until they are. Every other locale over the cut-off ships, even one no operating system language selects, such as `pirate`; the table names those too.
The app picks the catalog for the language chosen in its settings, or else for the operating system's language. Right-to-left locales such as Arabic, Hebrew and Persian pass the cut-off and still do not ship, and the table names them: the page already follows a catalog's text direction, but the app's styles are not right-to-left yet, so those locales wait until they are. Every other locale over the cut-off ships, even one no operating system language selects, such as `pirate`; the table names those too.

## The documentation site

Expand Down
1 change: 1 addition & 0 deletions docs/guide/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ The app's settings are in one dialog, opened from the cog at the bottom right of

## General

- **Language** — which language the app is shown in: your system's language, which is the default, or one of the languages the app has a translation for. A change applies after a relaunch, which the dialog offers; relaunching stops running servers and builds, as quitting does. Translations come from [translate.wordpress.org](https://translate.wordpress.org/projects/meta/contributor-toolkit/) and ship with the app once they are mostly complete, so the list grows from release to release.
- **New sites go here** — the folder new sites are created in, each in a subfolder of its own. With one set, the [create-site dialog](./creating-a-site) starts on it; you can still pick another folder for one site without changing the setting. **Forget this folder** clears it, and the dialog goes back to asking each time. A folder that no longer exists is refused when you choose it.

## Account
Expand Down
31 changes: 30 additions & 1 deletion src/i18n.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -140,4 +140,33 @@ async function resolveCatalog(locales, dir, log = () => {}) {
return null;
}

module.exports = { resolveCatalog, catalogCandidates, catalogsFromNames, slugTag };
/**
* The languages the app can be asked to show (#559): English, which is the
* source, and one for each catalog among `names`, each named in itself
* ("Deutsch", "Português do Brasil") so that someone looking for their own
* finds it whatever the app is showing. Sorted by that name.
*
* A tag with no name of its own in the ICU data is shown as the tag. The
* slugs no operating system reports (`pirate`, `art-xemoji`) are not here,
* as they are not selectable from the OS list either.
*
* @param {string[]} names File names in the catalog directory.
* @return {Array<{tag: string, label: string}>}
*/
function languageChoices(names) {
const choices = [{ tag: 'en', label: 'English' }];
for (const tag of catalogsFromNames(names).keys()) {
if (tag === 'en') continue;
let label = tag;
try {
// ICU writes some names as they are mid-sentence ("português");
// a list's entries are written as its first word.
const named = new Intl.DisplayNames([tag], { type: 'language' }).of(tag) || tag;
label = named.charAt(0).toLocaleUpperCase(tag) + named.slice(1);
} catch {}
choices.push({ tag, label });
}
return choices.sort((a, b) => a.label.localeCompare(b.label, 'en'));
}

module.exports = { resolveCatalog, catalogCandidates, catalogsFromNames, slugTag, languageChoices };
2 changes: 1 addition & 1 deletion src/languages/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@ One JSON file per locale, written by `npm run i18n:download` from translate.word

Each file is named by translate.wordpress.org's locale slug, which is lowercase: `de.json`, `pt-br.json`, `zh-tw.json`. The format is the `jed1x` export, and `src/i18n.cjs` reads its `locale_data.messages`.

The app walks the operating system's languages in order and loads the first one with a file, trying the exact tag, then its language and region, then for a tag with a script that script's usual region, then the bare language. So `es-MX` loads `es-mx.json`, `de-AT` loads `de.json`, and `zh-Hans-US` (macOS adds the user's region) loads `zh-cn.json`. English before another language keeps the English source strings. A slug and the tag an operating system reports are matched through `Intl.getCanonicalLocales`, which covers the three-letter slugs (`bel` is `be`) and Filipino (`tl` is `fil`); Valencian's `ca-val` is the one entry kept by hand, in `src/i18n.cjs`. A `--lang` switch replaces the operating system's list. A locale with no file keeps the English source strings.
The app walks the language chosen in its settings, where one is, and then the operating system's languages in order, and loads the first one with a file, trying the exact tag, then its language and region, then for a tag with a script that script's usual region, then the bare language. So `es-MX` loads `es-mx.json`, `de-AT` loads `de.json`, and `zh-Hans-US` (macOS adds the user's region) loads `zh-cn.json`. English before another language keeps the English source strings. A slug and the tag an operating system reports are matched through `Intl.getCanonicalLocales`, which covers the three-letter slugs (`bel` is `be`) and Filipino (`tl` is `fil`); Valencian's `ca-val` is the one entry kept by hand, in `src/i18n.cjs`. A `--lang` switch replaces both the setting and the operating system's list. A locale with no file keeps the English source strings. The settings offer English and one entry per file an operating system could report, each named in its own language.
83 changes: 71 additions & 12 deletions src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ const DEEP_LINK_CHANNEL = 'deep-link:ticket';
// here has to know which kind it is holding.
const { workItemProvider } = require('./work-item.cjs');
const { LEGACY_SITE_ERROR } = require('./renderer/legacy-site.cjs');
const { resolveCatalog } = require('./i18n.cjs');
const { resolveCatalog, languageChoices } = require('./i18n.cjs');
const { isPseudoLocale } = require('./renderer/pseudo-locale.cjs');
const { applyLocale } = require('./renderer/locale-setup.cjs');
const { __, setLocaleData } = require('@wordpress/i18n');
Expand Down Expand Up @@ -666,30 +666,84 @@ ipcMain.handle('deep-link:ready', () => {
return true;
});

// The language the window shows: the first of the OS's languages that has a
// catalog. `app.getLocale()` is only the fallback, since it is Chromium's UI
// language, folded into the 55 Chromium ships (Spanish (Mexico) arrives as
// es-419, Galician as English) (#584). A `--lang` switch replaces the OS list,
// read off the switch itself so `--lang=es-MX` is not folded either; it is how
// the journeys pick a locale, the pseudo-locale included.
// The language the window shows: the one chosen in the settings (#559), put
// before the OS's languages and not in their place, so that a chosen language
// whose catalog a release has since dropped falls back to the OS's and not
// to English; or else the first of the OS's languages that has a catalog.
// `app.getLocale()` is only the fallback, since it is Chromium's UI language,
// folded into the 55 Chromium ships (Spanish (Mexico) arrives as es-419,
// Galician as English) (#584). A `--lang` switch replaces the whole list,
// read off the switch itself so `--lang=es-MX` is not folded either; it is
// how the journeys pick a locale, the pseudo-locale included, and a flag
// typed at launch is a decision.
//
// Resolved once: main applies it at startup for its own strings (the menu, the
// native dialogs, the sentences it sends), and the window gets the same reply,
// so the two cannot end up in different languages.
// so the two cannot end up in different languages. That is also why a change
// in the settings shows after a relaunch and not before. This is the first
// read of the store, before there is a window: a store that cannot be read
// is logged and counts as no choice, since the window has to open to say so.
const LANGUAGES_DIR = path.join(__dirname, 'languages');
let localeReplyPromise = null;
function localeReply() {
if (!localeReplyPromise) {
localeReplyPromise = (async () => {
const requested = app.commandLine.getSwitchValue('lang');
const flag = app.commandLine.getSwitchValue('lang');
let chosen = null;
try {
chosen = readSettings((await getStore()).get('preferences')).locale;
} catch (e) {
logError('i18n', `the settings could not be read, so no language is chosen: ${String(e && e.message ? e.message : e)}`);
}
const requested = flag || chosen || '';
if (isPseudoLocale(requested)) return { locale: requested, data: null };
const locales = requested ? [requested] : [...app.getPreferredSystemLanguages(), app.getLocale()];
const found = await resolveCatalog(locales, path.join(__dirname, 'languages'), (message) => logEvent('i18n', message));
const system = [...app.getPreferredSystemLanguages(), app.getLocale()];
const locales = flag ? [flag] : [...(chosen ? [chosen] : []), ...system];
const found = await resolveCatalog(locales, LANGUAGES_DIR, (message) => logEvent('i18n', message));
return found ? { locale: found.locale, data: found.messages } : { locale: 'en', data: null };
})();
}
return localeReplyPromise;
}

// The languages the settings offer: what the build ships, read once.
let languagesPromise = null;
function languages() {
if (!languagesPromise) {
languagesPromise = fs.promises.readdir(LANGUAGES_DIR)
.catch((e) => {
logEvent('i18n', `no catalogs listed: ${e.message}`);
return [];
})
.then((names) => languageChoices(names));
}
return languagesPromise;
}

ipcMain.handle('i18n:languages', async () => ({ ok: true, languages: await languages() }));

// What the settings dialog offers after the language is changed. `quit`, not
// `exit`: the quit sweep ends every child the app started, as it does on any
// quit, and the relaunch is a quit. The new instance gets this one's
// arguments less two: a `wpct://` address a cold start was given, which is
// not a second request for its ticket, and a `--lang` switch, which would
// outrank the language just chosen.
function relaunchArgs(argv) {
return argv.slice(1).filter((arg) => !pickDeepLinkArg([arg]) && !arg.startsWith('--lang='));
}

// On Linux the app is an AppImage, mounted while it runs at the path the
// process was started from and gone once it quits: the new instance is
// started from the image itself.
ipcMain.handle('app:relaunch', () => {
app.relaunch({
args: relaunchArgs(process.argv),
...(process.env.APPIMAGE ? { execPath: process.env.APPIMAGE } : {})
});
app.quit();
return { ok: true };
});

ipcMain.handle('i18n:locale', () => localeReply());

// Without the lock, a link clicked while the app is running starts a second copy
Expand Down Expand Up @@ -3443,7 +3497,12 @@ ipcMain.handle('settings:set', async (_e, key, value) => {
// The disk is asked only about a full path for a setting there is: what
// the pure check would ask, and nothing a key that is not a setting sends.
const directory = Object.hasOwn(SETTINGS, key) && typeof value === 'string' && path.isAbsolute(value) && await isDirectory(value);
const accepted = acceptSetting(key, value, { isAbsolute: path.isAbsolute, isDirectory: () => directory });
const known = await languages();
const accepted = acceptSetting(key, value, {
isAbsolute: path.isAbsolute,
isDirectory: () => directory,
isLanguage: (tag) => known.some((language) => language.tag === tag)
});
if (!accepted.ok) return { ok: false, error: accepted.error };
await setPreference(key, accepted.value);
const s = await getStore();
Expand Down
6 changes: 6 additions & 0 deletions src/preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,12 @@ contextBridge.exposeInMainWorld('api', {
getSettings: () => ipcRenderer.invoke('settings:get')
,
setSetting: (key, value) => ipcRenderer.invoke('settings:set', key, value)
,
// The languages the app can show, for the settings; and the relaunch a
// change of language takes.
listLanguages: () => ipcRenderer.invoke('i18n:languages')
,
relaunch: () => ipcRenderer.invoke('app:relaunch')
,
// The menu's "Settings…" asked for the dialog.
subscribeSettingsOpen: (handler) => {
Expand Down
Loading
Loading