(core) User language switcher

Summary:
New language selector on the Account page for logged-in users.
New icon for switching language for an anonymous user.

For anonymous users, language is stored in a cookie grist_user_locale.
Language is stored in user settings for authenticated users and takes
precedence over what is stored in the cookie.

Test Plan: New tests

Reviewers: paulfitz

Reviewed By: paulfitz

Differential Revision: https://phab.getgrist.com/D3766
This commit is contained in:
Jarosław Sadziński
2023-01-24 14:13:18 +01:00
parent abea735470
commit 90d3ee037a
36 changed files with 696 additions and 74 deletions

View File

@@ -373,6 +373,14 @@ export async function addRequestUser(dbManager: HomeDBManager, permitStore: IPer
mreq.users = [dbManager.makeFullUser(anon)];
}
if (mreq.userId) {
if (mreq.user?.options?.locale) {
mreq.language = mreq.user.options.locale;
// This is a synchronous call (as it was configured with initImmediate: false).
mreq.i18n.changeLanguage(mreq.language).catch(() => {});
}
}
const meta = {
customHostSession,
method: mreq.method,

View File

@@ -122,7 +122,7 @@ export function getDocSessionUser(docSession: OptDocSession): FullUser|null {
const user = getUser(docSession.req);
const email = user.loginEmail;
if (email) {
return {id: user.id, name: user.name, email, ref: user.ref};
return {id: user.id, name: user.name, email, ref: user.ref, locale: user.options?.locale};
}
}
if (docSession.client) {

View File

@@ -59,7 +59,7 @@ import {startTestingHooks} from 'app/server/lib/TestingHooks';
import {getTestLoginSystem} from 'app/server/lib/TestLogin';
import {addUploadRoute} from 'app/server/lib/uploads';
import {buildWidgetRepository, IWidgetRepository} from 'app/server/lib/WidgetRepository';
import {readLoadedLngs, readLoadedNamespaces, setupLocale} from 'app/server/localization';
import {setupLocale} from 'app/server/localization';
import axios from 'axios';
import * as bodyParser from 'body-parser';
import express from 'express';
@@ -1278,10 +1278,7 @@ export class FlexServer implements GristServer {
}
public getGristConfig(): GristLoadConfig {
return makeGristConfig(this.getDefaultHomeUrl(), {
supportedLngs: readLoadedLngs(this.i18Instance),
namespaces: readLoadedNamespaces(this.i18Instance),
}, this._defaultBaseDomain);
return makeGristConfig(this.getDefaultHomeUrl(), {}, this._defaultBaseDomain);
}
/**

View File

@@ -65,6 +65,7 @@ export function makeGristConfig(homeUrl: string|null, extra: Partial<GristLoadCo
namespaces: readLoadedNamespaces(req?.i18n),
featureComments: process.env.COMMENTS === "true",
supportEmail: SUPPORT_EMAIL,
userLocale: (req as RequestWithLogin | undefined)?.user?.options?.locale,
...extra,
};
}
@@ -111,9 +112,12 @@ export function makeSendAppPage(opts: {
const customHeadHtmlSnippet = server?.create.getExtraHeadHtml?.() ?? "";
const warning = testLogin ? "<div class=\"dev_warning\">Authentication is not enforced</div>" : "";
// Preload all languages that will be used or are requested by client.
const preloads = req.languages.map((lng) =>
readLoadedNamespaces(req.i18n).map((ns) =>
`<link rel="preload" href="locales/${lng}.${ns}.json" as="fetch" type="application/json" crossorigin>`
const preloads = req.languages
.filter(lng => (readLoadedLngs(req.i18n)).includes(lng))
.map(lng => lng.replace('-', '_'))
.map((lng) =>
readLoadedNamespaces(req.i18n).map((ns) =>
`<link rel="preload" href="locales/${lng}.${ns}.json" as="fetch" type="application/json" crossorigin>`
).join("\n")
).join('\n');
const content = fileContent

View File

@@ -1,6 +1,5 @@
import {lstatSync, readdirSync} from 'fs';
import {lstatSync, readdirSync, readFileSync} from 'fs';
import {createInstance, i18n} from 'i18next';
import i18fsBackend from 'i18next-fs-backend';
import {LanguageDetector} from 'i18next-http-middleware';
import path from 'path';
@@ -10,42 +9,41 @@ export function setupLocale(appRoot: string): i18n {
// By default locales are located in the appRoot folder, unless the environment variable
// GRIST_LOCALES_DIR is set.
const localeDir = process.env.GRIST_LOCALES_DIR || path.join(appRoot, 'static', 'locales');
const preload: [string, string, string][] = [];
const supportedNamespaces: Set<string> = new Set();
const supportedLngs: Set<string> = new Set(readdirSync(localeDir).map((fileName) => {
const supportedLngs: Set<string> = new Set();
for(const fileName of readdirSync(localeDir)) {
const fullPath = path.join(localeDir, fileName);
const isDirectory = lstatSync(fullPath).isDirectory();
if (isDirectory) {
return "";
continue;
}
const baseName = path.basename(fileName, '.json');
const lang = baseName.split('.')[0];
const lang = baseName.split('.')[0]?.replace(/_/g, '-');
const namespace = baseName.split('.')[1];
if (!lang || !namespace) {
throw new Error("Unrecognized resource file " + fileName);
}
supportedNamespaces.add(namespace);
return lang;
}).filter((lang) => lang));
preload.push([lang, namespace, fullPath]);
supportedLngs.add(lang);
}
if (!supportedLngs.has('en') || !supportedNamespaces.has('server')) {
throw new Error("Missing server English language file");
}
// Initialize localization filesystem plugin that will read the locale files from the localeDir.
instance.use(i18fsBackend);
// Initialize localization language detector plugin that will read the language from the request.
instance.use(LanguageDetector);
let errorDuringLoad: Error | undefined;
instance.init({
// Load all files synchronously.
initImmediate: false,
preload: [...supportedLngs],
supportedLngs: [...supportedLngs],
defaultNS: 'server',
ns: [...supportedNamespaces],
fallbackLng: 'en',
backend: {
loadPath: `${localeDir}/{{lng}}.{{ns}}.json`
},
detection: {
lookupCookie: 'grist_user_locale'
}
}, (err: any) => {
if (err) {
errorDuringLoad = err;
@@ -56,14 +54,19 @@ export function setupLocale(appRoot: string): i18n {
console.error("i18next failed unexpectedly", err);
});
if (errorDuringLoad) {
console.error('i18next failed to load', errorDuringLoad);
throw errorDuringLoad;
}
// Load all files synchronously.
for(const [lng, ns, fullPath] of preload) {
instance.addResourceBundle(lng, ns, JSON.parse(readFileSync(fullPath, 'utf8')));
}
return instance;
}
export function readLoadedLngs(instance?: i18n): readonly string[] {
if (!instance) { return []; }
return instance?.options.preload || ['en'];
return Object.keys(instance?.services.resourceStore.data);
}
export function readLoadedNamespaces(instance?: i18n): readonly string[] {