mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(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:
@@ -6,18 +6,7 @@ import {G} from 'grainjs/dist/cjs/lib/browserGlobals';
|
||||
export async function setupLocale() {
|
||||
const now = Date.now();
|
||||
const supportedLngs = getGristConfig().supportedLngs ?? ['en'];
|
||||
let lng = window.navigator.language || 'en';
|
||||
// If user agent language is not in the list of supported languages, use the default one.
|
||||
lng = lng.replace(/-/g, '_');
|
||||
if (!supportedLngs.includes(lng)) {
|
||||
// Test if server supports general language.
|
||||
if (lng.includes("_") && supportedLngs.includes(lng.split("_")[0])) {
|
||||
lng = lng.split("_")[0]!;
|
||||
} else {
|
||||
lng = 'en';
|
||||
}
|
||||
}
|
||||
|
||||
const lng = detectCurrentLang();
|
||||
const ns = getGristConfig().namespaces ?? ['client'];
|
||||
// Initialize localization plugin
|
||||
try {
|
||||
@@ -25,8 +14,6 @@ export async function setupLocale() {
|
||||
i18next.init({
|
||||
// By default we use english language.
|
||||
fallbackLng: 'en',
|
||||
// Fallback from en-US, en-GB, etc to en.
|
||||
nonExplicitSupportedLngs: true,
|
||||
// We will load resources ourselves.
|
||||
initImmediate: false,
|
||||
// Read language from navigator object.
|
||||
@@ -38,8 +25,7 @@ export async function setupLocale() {
|
||||
// for now just import all what server offers.
|
||||
// We can fallback to client namespace for any addons.
|
||||
fallbackNS: 'client',
|
||||
ns,
|
||||
supportedLngs
|
||||
ns
|
||||
}).catch((err: any) => {
|
||||
// This should not happen, the promise should be resolved synchronously, without
|
||||
// any errors reported.
|
||||
@@ -51,14 +37,14 @@ export async function setupLocale() {
|
||||
const loadPath = `${document.baseURI}locales/{{lng}}.{{ns}}.json`;
|
||||
const pathsToLoad: Promise<any>[] = [];
|
||||
async function load(lang: string, n: string) {
|
||||
const resourceUrl = loadPath.replace('{{lng}}', lang).replace('{{ns}}', n);
|
||||
const resourceUrl = loadPath.replace('{{lng}}', lang.replace("-", "_")).replace('{{ns}}', n);
|
||||
const response = await fetch(resourceUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load ${resourceUrl}`);
|
||||
}
|
||||
i18next.addResourceBundle(lang, n, await response.json());
|
||||
}
|
||||
for (const lang of languages) {
|
||||
for (const lang of languages.filter((l) => supportedLngs.includes(l))) {
|
||||
for (const n of ns) {
|
||||
pathsToLoad.push(load(lang, n));
|
||||
}
|
||||
@@ -70,6 +56,25 @@ export async function setupLocale() {
|
||||
}
|
||||
}
|
||||
|
||||
export function detectCurrentLang() {
|
||||
const { userLocale, supportedLngs } = getGristConfig();
|
||||
const detected = userLocale
|
||||
|| document.cookie.match(/grist_user_locale=([^;]+)/)?.[1]
|
||||
|| window.navigator.language
|
||||
|| 'en';
|
||||
const supportedList = supportedLngs ?? ['en'];
|
||||
// If we have this language in the list (or more general version) mark it as selected.
|
||||
// Compare languages in lower case, as navigator.language can return en-US, en-us (for older Safari).
|
||||
const selected = supportedList.find(supported => supported.toLowerCase() === detected.toLowerCase()) ??
|
||||
supportedList.find(supported => supported === detected.split(/[-_]/)[0]) ?? 'en';
|
||||
return selected;
|
||||
}
|
||||
|
||||
export function setAnonymousLocale(lng: string) {
|
||||
document.cookie = lng ? `grist_user_locale=${lng}; path=/; max-age=31536000`
|
||||
: 'grist_user_locale=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC';
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the translation of the given key using the given options.
|
||||
*/
|
||||
|
||||
@@ -63,6 +63,7 @@ export interface IEditableMember {
|
||||
name: string;
|
||||
email: string;
|
||||
picture?: string|null;
|
||||
locale?: string|null;
|
||||
access: Observable<roles.Role|null>;
|
||||
parentAccess: roles.BasicRole|null;
|
||||
inheritedAccess: Computed<roles.BasicRole|null>;
|
||||
|
||||
@@ -13,10 +13,12 @@ import {transientInput} from 'app/client/ui/transientInput';
|
||||
import {cssBreadcrumbs, separator} from 'app/client/ui2018/breadcrumbs';
|
||||
import {labeledSquareCheckbox} from 'app/client/ui2018/checkbox';
|
||||
import {cssLink} from 'app/client/ui2018/links';
|
||||
import {select} from 'app/client/ui2018/menus';
|
||||
import {getGristConfig} from 'app/common/urlUtils';
|
||||
import {FullUser} from 'app/common/UserAPI';
|
||||
import {detectCurrentLang, makeT} from 'app/client/lib/localization';
|
||||
import {translateLocale} from 'app/client/ui/LanguageMenu';
|
||||
import {Computed, Disposable, dom, domComputed, makeTestId, Observable, styled} from 'grainjs';
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
|
||||
const testId = makeTestId('test-account-page-');
|
||||
const t = makeT('AccountPage');
|
||||
@@ -56,6 +58,22 @@ export class AccountPage extends Disposable {
|
||||
|
||||
private _buildContentMain() {
|
||||
const {enableCustomCss} = getGristConfig();
|
||||
const supportedLngs = getGristConfig().supportedLngs ?? ['en'];
|
||||
const languageOptions = supportedLngs
|
||||
.map((lng) => ({value: lng, label: translateLocale(lng)!}))
|
||||
.sort((a, b) => a.value.localeCompare(b.value));
|
||||
|
||||
const userLocale = Computed.create(this, use => {
|
||||
const selected = detectCurrentLang();
|
||||
if (!supportedLngs.includes(selected)) { return 'en'; }
|
||||
return selected;
|
||||
});
|
||||
userLocale.onWrite(async value => {
|
||||
await this._appModel.api.updateUserLocale(value || null);
|
||||
// Reload the page to apply the new locale.
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
return domComputed(this._userObs, (user) => user && (
|
||||
css.container(css.accountPage(
|
||||
css.header(t("Account settings")),
|
||||
@@ -73,7 +91,7 @@ export class AccountPage extends Disposable {
|
||||
save: (val) => this._isNameValid.get() && this._updateUserName(val),
|
||||
close: () => { this._isEditingName.set(false); this._nameEdit.set(''); },
|
||||
},
|
||||
{ size: '5' }, // Lower size so that input can shrink below ~152px.
|
||||
{size: '5'}, // Lower size so that input can shrink below ~152px.
|
||||
dom.on('input', (_ev, el) => this._nameEdit.set(el.value)),
|
||||
css.flexGrow.cls(''),
|
||||
),
|
||||
@@ -92,7 +110,7 @@ export class AccountPage extends Disposable {
|
||||
testId('username'),
|
||||
),
|
||||
// show warning for invalid name but not for the empty string
|
||||
dom.maybe(use => use(this._nameEdit) && !use(this._isNameValid), cssWarnings),
|
||||
dom.maybe(use => use(this._nameEdit) && !use(this._isNameValid), this._buildNameWarningsDom.bind(this)),
|
||||
css.header(t("Password & Security")),
|
||||
css.dataRow(
|
||||
css.inlineSubHeader(t("Login Method")),
|
||||
@@ -123,6 +141,15 @@ export class AccountPage extends Disposable {
|
||||
enableCustomCss ? null : [
|
||||
css.header(t("Theme")),
|
||||
dom.create(ThemeConfig, this._appModel),
|
||||
css.subHeader(t("Language ")),
|
||||
css.dataRow({ style: 'width: 300px'},
|
||||
select(userLocale, languageOptions, {
|
||||
renderOptionArgs: () => {
|
||||
return dom.cls(cssFirstUpper.className);
|
||||
}
|
||||
}),
|
||||
testId('language'),
|
||||
)
|
||||
],
|
||||
css.header(t("API")),
|
||||
css.dataRow(css.inlineSubHeader(t("API Key")), css.content(
|
||||
@@ -131,7 +158,7 @@ export class AccountPage extends Disposable {
|
||||
onCreate: () => this._createApiKey(),
|
||||
onDelete: () => this._deleteApiKey(),
|
||||
anonymous: false,
|
||||
inputArgs: [{ size: '5' }], // Lower size so that input can shrink below ~152px.
|
||||
inputArgs: [{size: '5'}], // Lower size so that input can shrink below ~152px.
|
||||
})
|
||||
)),
|
||||
),
|
||||
@@ -141,7 +168,7 @@ export class AccountPage extends Disposable {
|
||||
|
||||
private _buildHeaderMain() {
|
||||
return dom.frag(
|
||||
cssBreadcrumbs({ style: 'margin-left: 16px;' },
|
||||
cssBreadcrumbs({style: 'margin-left: 16px;'},
|
||||
cssLink(
|
||||
urlState().setLinkUrl({}),
|
||||
'Home',
|
||||
@@ -194,6 +221,16 @@ export class AccountPage extends Disposable {
|
||||
private _showChangePasswordDialog() {
|
||||
return buildChangePasswordDialog();
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds dom to show marning messages to the user.
|
||||
*/
|
||||
private _buildNameWarningsDom() {
|
||||
return cssWarnings(
|
||||
t("Names only allow letters, numbers and certain special characters"),
|
||||
testId('username-warning'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -211,16 +248,14 @@ export function checkName(name: string): boolean {
|
||||
return VALID_NAME_REGEXP.test(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds dom to show marning messages to the user.
|
||||
*/
|
||||
function buildNameWarningsDom() {
|
||||
return css.warning(
|
||||
t("Names only allow letters, numbers and certain special characters"),
|
||||
testId('username-warning'),
|
||||
);
|
||||
}
|
||||
|
||||
const cssWarnings = styled(buildNameWarningsDom, `
|
||||
|
||||
const cssWarnings = styled(css.warning, `
|
||||
margin: -8px 0 0 110px;
|
||||
`);
|
||||
|
||||
const cssFirstUpper = styled('div', `
|
||||
& > div::first-letter {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
`);
|
||||
|
||||
@@ -98,6 +98,7 @@ export const dataRow = styled('div', `
|
||||
margin: 8px 0px;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 2px;
|
||||
`);
|
||||
|
||||
export const betaTag = styled('span', `
|
||||
|
||||
@@ -86,7 +86,7 @@ export class AccountWidget extends Disposable {
|
||||
cssEmail(user.email, testId('usermenu-email'))
|
||||
)
|
||||
),
|
||||
menuItemLink(urlState().setLinkUrl({account: 'account'}), t("Profile Settings")),
|
||||
menuItemLink(urlState().setLinkUrl({account: 'account'}), t("Profile Settings"), testId('dm-account-settings')),
|
||||
|
||||
documentSettingsItem,
|
||||
|
||||
|
||||
107
app/client/ui/LanguageMenu.ts
Normal file
107
app/client/ui/LanguageMenu.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import {detectCurrentLang, makeT, setAnonymousLocale} from 'app/client/lib/localization';
|
||||
import {AppModel} from 'app/client/models/AppModel';
|
||||
import {hoverTooltip} from 'app/client/ui/tooltips';
|
||||
import {cssHoverCircle, cssTopBarBtn} from 'app/client/ui/TopBarCss';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {menu, menuItem} from 'app/client/ui2018/menus';
|
||||
import {getCountryCode} from 'app/common/Locales';
|
||||
import {getGristConfig} from 'app/common/urlUtils';
|
||||
import {dom, makeTestId, styled} from 'grainjs';
|
||||
|
||||
const testId = makeTestId('test-language-');
|
||||
const t = makeT('LanguageMenu');
|
||||
|
||||
export function buildLanguageMenu(appModel: AppModel) {
|
||||
// Get the list of languages from the config, or default to English.
|
||||
const languages = getGristConfig().supportedLngs ?? ['en'];
|
||||
// Get the current language (from user's preference, cookie or browser)
|
||||
const userLanguage = detectCurrentLang();
|
||||
|
||||
if (appModel.currentValidUser) {
|
||||
// For logged in users, we don't need to show the menu (they have a preference in their profile).
|
||||
// But for tests we will show a hidden indicator.
|
||||
return dom('input', {type: 'hidden'}, (testId(`current-` + userLanguage)));
|
||||
}
|
||||
|
||||
// When we switch language, we need to reload the page to get the new translations.
|
||||
// This button is only for anonymous users, so we don't need to save the preference or wait for anything.
|
||||
const changeLanguage = (lng: string) => {
|
||||
setAnonymousLocale(lng);
|
||||
window.location.reload();
|
||||
};
|
||||
// Try to convert locale setting to the emoji flag, fallback to plain flag icon.
|
||||
const emojiFlag = buildEmoji(userLanguage);
|
||||
return cssHoverCircle(
|
||||
// Margin is common for all hover buttons on TopBar.
|
||||
{style: `margin: 5px;`},
|
||||
// Flag or emoji flag if we have it.
|
||||
emojiFlag ?? cssTopBarBtn('Flag'),
|
||||
// Expose for test the current language use.
|
||||
testId(`current-` + userLanguage),
|
||||
menu(
|
||||
// Convert the list of languages we support to menu items.
|
||||
() => languages.map((lng) => menuItem(() => changeLanguage(lng), [
|
||||
// Try to convert the locale to nice name, fallback to locale itself.
|
||||
cssFirstUpper(translateLocale(lng) ?? lng),
|
||||
// If this is current language, mark it with a tick (by default we mark en).
|
||||
userLanguage === lng ? cssWrapper(icon('Tick'), testId('selected')) : null,
|
||||
testId(`lang-` + lng),
|
||||
])),
|
||||
{
|
||||
placement: 'bottom-end',
|
||||
}
|
||||
),
|
||||
hoverTooltip(t('Language'), {key: 'topBarBtnTooltip'}),
|
||||
testId('button'),
|
||||
);
|
||||
}
|
||||
|
||||
// Unfortunately, Windows doesn't support emoji flags, so we need to use SVG icons.
|
||||
function buildEmoji(locale: string) {
|
||||
const countryCode = getCountryCode(locale);
|
||||
if (!countryCode) { return null; }
|
||||
return [
|
||||
cssSvgIcon({
|
||||
style: `background-image: url("icons/locales/${countryCode}.svg")`
|
||||
}),
|
||||
dom.cls(cssSvgIconWrapper.className)
|
||||
];
|
||||
}
|
||||
|
||||
export function translateLocale(locale: string) {
|
||||
try {
|
||||
locale = locale.replace("_", "-");
|
||||
// This API might not be available in all browsers.
|
||||
const languageNames = new Intl.DisplayNames([locale], {type: 'language'});
|
||||
return languageNames.of(locale) || null;
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const cssWrapper = styled('div', `
|
||||
margin-left: auto;
|
||||
display: inline-block;
|
||||
`);
|
||||
|
||||
const cssSvgIconWrapper = styled('div', `
|
||||
display: grid;
|
||||
place-content: center;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
`);
|
||||
|
||||
const cssSvgIcon = styled('div', `
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
background-color: transparent;
|
||||
background-size: contain;
|
||||
`);
|
||||
|
||||
const cssFirstUpper = styled('span', `
|
||||
&::first-letter {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
`);
|
||||
@@ -11,6 +11,7 @@ import {manageTeamUsersApp} from 'app/client/ui/OpenUserManager';
|
||||
import {buildShareMenuButton} from 'app/client/ui/ShareMenu';
|
||||
import {hoverTooltip} from 'app/client/ui/tooltips';
|
||||
import {cssHoverCircle, cssTopBarBtn} from 'app/client/ui/TopBarCss';
|
||||
import {buildLanguageMenu} from 'app/client/ui/LanguageMenu';
|
||||
import {docBreadcrumbs} from 'app/client/ui2018/breadcrumbs';
|
||||
import {basicButton} from 'app/client/ui2018/buttons';
|
||||
import {cssHideForNarrowScreen, testId, theme} from 'app/client/ui2018/cssVars';
|
||||
@@ -38,6 +39,7 @@ export function createTopBarHome(appModel: AppModel) {
|
||||
null
|
||||
),
|
||||
|
||||
buildLanguageMenu(appModel),
|
||||
buildNotifyMenuButton(appModel.notifier, appModel),
|
||||
dom('div', dom.create(AccountWidget, appModel)),
|
||||
];
|
||||
|
||||
@@ -622,7 +622,8 @@ function getFullUser(member: IEditableMember): FullUser {
|
||||
id: member.id,
|
||||
name: member.name,
|
||||
email: member.email,
|
||||
picture: member.picture
|
||||
picture: member.picture,
|
||||
locale: member.locale
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ export type IconName = "ChartArea" |
|
||||
"Filter" |
|
||||
"FilterSimple" |
|
||||
"Fireworks" |
|
||||
"Flag" |
|
||||
"Folder" |
|
||||
"FontBold" |
|
||||
"FontItalic" |
|
||||
@@ -195,6 +196,7 @@ export const IconList: IconName[] = ["ChartArea",
|
||||
"Filter",
|
||||
"FilterSimple",
|
||||
"Fireworks",
|
||||
"Flag",
|
||||
"Folder",
|
||||
"FontBold",
|
||||
"FontItalic",
|
||||
|
||||
@@ -330,10 +330,10 @@ export function saveModal(createFunc: (ctl: IModalControl, owner: MultiHolder) =
|
||||
* See saveModal() for error handling notes that here apply to the onConfirm callback.
|
||||
*/
|
||||
export function confirmModal(
|
||||
title: string,
|
||||
btnText: string,
|
||||
title: DomElementArg,
|
||||
btnText: DomElementArg,
|
||||
onConfirm: () => Promise<void>,
|
||||
explanation?: Element|string,
|
||||
explanation?: DomElementArg,
|
||||
{hideCancel, extraButtons}: {hideCancel?: boolean, extraButtons?: DomContents} = {},
|
||||
): void {
|
||||
return saveModal((ctl, owner): ISaveModalOptions => ({
|
||||
|
||||
@@ -68,3 +68,17 @@ try {
|
||||
}
|
||||
|
||||
currencies = [...currencies].sort((a, b) => nativeCompare(a.code, b.code));
|
||||
|
||||
|
||||
export function getCountryCode(locale: string) {
|
||||
// We have some defaults defined.
|
||||
if (locale === 'en') { return 'US'; }
|
||||
let countryCode = locale.split(/[-_]/)[1];
|
||||
if (countryCode) { return countryCode.toUpperCase(); }
|
||||
countryCode = locale.toUpperCase();
|
||||
// Test if we can use language as a country code.
|
||||
if (localeCodes.map(code => code.split(/[-_]/)[1]).includes(countryCode)) {
|
||||
return countryCode;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface UserProfile {
|
||||
anonymous?: boolean; // when present, asserts whether user is anonymous (not authorized).
|
||||
connectId?: string|null, // used by GristConnect to identify user in external provider.
|
||||
loginMethod?: 'Google'|'Email + Password'|'External';
|
||||
locale?: string|null;
|
||||
}
|
||||
|
||||
// User profile including user id and user ref. All information in it should
|
||||
|
||||
@@ -31,6 +31,8 @@ export interface UserPrefs extends Prefs {
|
||||
behavioralPrompts?: BehavioralPromptPrefs;
|
||||
// Welcome popups a user has dismissed.
|
||||
dismissedWelcomePopups?: DismissedReminder[];
|
||||
// Localization support.
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
// A collection of preferences related to a combination of user and org.
|
||||
|
||||
@@ -141,6 +141,8 @@ export interface UserOptions {
|
||||
// Whether user is a consultant. Consultant users can be added to sites
|
||||
// without being counted for billing. Defaults to false if unset.
|
||||
isConsultant?: boolean;
|
||||
// Locale selected by the user. Defaults to 'en' if unset.
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
export interface PermissionDelta {
|
||||
@@ -331,6 +333,7 @@ export interface UserAPI {
|
||||
moveDoc(docId: string, workspaceId: number): Promise<void>;
|
||||
getUserProfile(): Promise<FullUser>;
|
||||
updateUserName(name: string): Promise<void>;
|
||||
updateUserLocale(locale: string|null): Promise<void>;
|
||||
updateAllowGoogleLogin(allowGoogleLogin: boolean): Promise<void>;
|
||||
updateIsConsultant(userId: number, isConsultant: boolean): Promise<void>;
|
||||
getWorker(key: string): Promise<string>;
|
||||
@@ -632,6 +635,13 @@ export class UserAPIImpl extends BaseAPI implements UserAPI {
|
||||
});
|
||||
}
|
||||
|
||||
public async updateUserLocale(locale: string|null): Promise<void> {
|
||||
await this.request(`${this._url}/api/profile/user/locale`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({locale})
|
||||
});
|
||||
}
|
||||
|
||||
public async updateAllowGoogleLogin(allowGoogleLogin: boolean): Promise<void> {
|
||||
await this.request(`${this._url}/api/profile/allowGoogleLogin`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -585,6 +585,9 @@ export interface GristLoadConfig {
|
||||
|
||||
// Email address of the support user.
|
||||
supportEmail?: string;
|
||||
|
||||
// Current user locale, read from the user options;
|
||||
userLocale?: string;
|
||||
}
|
||||
|
||||
export const HideableUiElements = StringUnion("helpCenter", "billing", "templates", "multiSite", "multiAccounts");
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import * as crypto from 'crypto';
|
||||
import * as express from 'express';
|
||||
import {EntityManager} from 'typeorm';
|
||||
import * as cookie from 'cookie';
|
||||
import {Request} from 'express';
|
||||
|
||||
import {ApiError} from 'app/common/ApiError';
|
||||
import {FullUser} from 'app/common/LoginSessionAPI';
|
||||
@@ -13,10 +15,10 @@ import log from 'app/server/lib/log';
|
||||
import {addPermit, clearSessionCacheIfNeeded, getDocScope, getScope, integerParam,
|
||||
isParameterOn, sendOkReply, sendReply, stringParam} from 'app/server/lib/requestUtils';
|
||||
import {IWidgetRepository} from 'app/server/lib/WidgetRepository';
|
||||
import {Request} from 'express';
|
||||
|
||||
import {User} from './entity/User';
|
||||
import {HomeDBManager, QueryResult, Scope} from './lib/HomeDBManager';
|
||||
import {getCookieDomain} from 'app/server/lib/gristSessions';
|
||||
|
||||
// Special public organization that contains examples and templates.
|
||||
export const TEMPLATES_ORG_DOMAIN = process.env.GRIST_ID_PREFIX ?
|
||||
@@ -371,6 +373,23 @@ export class ApiServer {
|
||||
res.sendStatus(200);
|
||||
}));
|
||||
|
||||
// POST /api/profile/user/locale
|
||||
// Body params: string
|
||||
// Update users profile.
|
||||
this._app.post('/api/profile/user/locale', expressWrap(async (req, res) => {
|
||||
const userId = getAuthorizedUserId(req);
|
||||
await this._dbManager.updateUserOptions(userId, {locale: req.body.locale || null});
|
||||
res.append('Set-Cookie', cookie.serialize('grist_user_locale', req.body.locale || '', {
|
||||
httpOnly: false, // make available to client-side scripts
|
||||
domain: getCookieDomain(req),
|
||||
path: '/',
|
||||
secure: true,
|
||||
maxAge: req.body.locale ? 31536000 : 0,
|
||||
sameSite: 'None', // there is no security concern to expose this information.
|
||||
}));
|
||||
res.sendStatus(200);
|
||||
}));
|
||||
|
||||
// POST /api/profile/allowGoogleLogin
|
||||
// Update user's preference for allowing Google login.
|
||||
this._app.post('/api/profile/allowGoogleLogin', expressWrap(async (req, res) => {
|
||||
|
||||
@@ -490,6 +490,7 @@ export class HomeDBManager extends EventEmitter {
|
||||
name: user.name,
|
||||
picture: user.picture,
|
||||
ref: user.ref,
|
||||
locale: user.options?.locale
|
||||
};
|
||||
if (this.getAnonymousUserId() === user.id) {
|
||||
result.anonymous = true;
|
||||
@@ -2663,7 +2664,8 @@ export class HomeDBManager extends EventEmitter {
|
||||
email: login.displayEmail,
|
||||
name: login.user.name,
|
||||
picture: login.user.picture,
|
||||
anonymous: login.user.id === this.getAnonymousUserId()
|
||||
anonymous: login.user.id === this.getAnonymousUserId(),
|
||||
locale: login.user.options?.locale
|
||||
};
|
||||
}
|
||||
return profiles.map(profile => completedProfiles[normalizeEmail(profile.email)])
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
Reference in New Issue
Block a user