Merge branch 'main' into column-description

pull/406/head
Camille 1 year ago
commit 95f1a41618

@ -6,7 +6,7 @@ import {cssMemberImage, cssMemberListItem, cssMemberPrimary,
import {testId, theme, vars} from 'app/client/ui2018/cssVars';
import {PermissionDataWithExtraUsers} from 'app/common/ActiveDocAPI';
import {menu, menuCssClass, menuItemLink} from 'app/client/ui2018/menus';
import {userOverrideParams} from 'app/common/gristUrls';
import {IGristUrlState, userOverrideParams} from 'app/common/gristUrls';
import {FullUser} from 'app/common/LoginSessionAPI';
import {ANONYMOUS_USER_EMAIL, EVERYONE_EMAIL} from 'app/common/UserAPI';
import {getRealAccess, UserAccessData} from 'app/common/UserAPI';
@ -66,12 +66,14 @@ export class ACLUsersPopup extends Disposable {
}
}
public attachPopup(elem: Element, options: IPopupOptions) {
// Optionnally have document page reverts to the default page upon activation of the view as mode
// by setting `options.resetDocPage` to true.
public attachPopup(elem: Element, options: IPopupOptions & {resetDocPage?: boolean}) {
setPopupToCreateDom(elem, (ctl) => {
const buildRow =
(user: UserAccessData) => this._buildUserRow(user);
(user: UserAccessData) => this._buildUserRow(user, options);
const buildExampleUserRow =
(user: UserAccessData) => this._buildUserRow(user, {isExampleUser: true});
(user: UserAccessData) => this._buildUserRow(user, {isExampleUser: true, ...options});
return cssMenuWrap(cssMenu(
dom.cls(menuCssClass),
cssUsers.cls(''),
@ -92,6 +94,7 @@ export class ACLUsersPopup extends Disposable {
}, {...defaultMenuOptions, ...options});
}
// See 'attachPopup' for more info on the 'resetDocPage' option.
public menu(options: IMenuOptions) {
return menu(() => {
this.load().catch(noop);
@ -116,7 +119,7 @@ export class ACLUsersPopup extends Disposable {
return this._shareUsers.length + this._attributeTableUsers.length < 5;
}
private _buildUserRow(user: UserAccessData, opt: {isExampleUser?: boolean} = {}) {
private _buildUserRow(user: UserAccessData, opt: {isExampleUser?: boolean, resetDocPage?: boolean} = {}) {
return dom('a',
{class: cssMemberListItem.className + ' ' + cssUserItem.className},
cssMemberImage(
@ -128,12 +131,14 @@ export class ACLUsersPopup extends Disposable {
),
user.name ? cssMemberSecondary(user.email) : null
),
this._viewAs(user),
this._viewAs(user, opt.resetDocPage),
testId('acl-user-item'),
);
}
private _viewAs(user: UserAccessData) {
private _viewAs(user: UserAccessData, resetDocPage: boolean = false) {
const extraState: IGristUrlState = {};
if (resetDocPage) { extraState.docPage = undefined; }
if (this.pageModel?.isPrefork.get() &&
this.pageModel?.currentDoc.get()?.access !== 'owners') {
// "View As" is restricted to document owners on the back-end. Non-owners can be
@ -145,13 +150,12 @@ export class ACLUsersPopup extends Disposable {
const forkResult = await this.pageModel?.gristDoc.get()?.docComm.fork();
if (!forkResult) { throw new Error('Failed to create fork'); }
window.location.assign(urlState().makeUrl(userOverrideParams(user.email,
{doc: forkResult.urlId,
docPage: undefined})));
{...extraState, doc: forkResult.urlId})));
});
} else {
// When forking isn't needed, we return a direct link to be maximally transparent
// about where button will go.
return urlState().setHref(userOverrideParams(user.email, {docPage: undefined}));
return urlState().setHref(userOverrideParams(user.email, extraState));
}
}
}

@ -381,7 +381,7 @@ export class AccessRules extends Disposable {
),
bigBasicButton(t('Add User Attributes'), dom.on('click', () => this._addUserAttributes())),
bigBasicButton(t('View As'), cssDropdownIcon('Dropdown'),
elem => this._aclUsersPopup.attachPopup(elem, {placement: 'bottom-end'}),
elem => this._aclUsersPopup.attachPopup(elem, {placement: 'bottom-end', resetDocPage: true}),
dom.style('visibility', use => use(this._aclUsersPopup.isInitialized) ? '' : 'hidden')),
),
cssConditionError({style: 'margin-left: 16px'},

@ -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.
*/
@ -90,11 +95,6 @@ type InferResult<T> = T extends Record<string, string | number | boolean>|undefi
* Resolves the translation of the given key and substitutes. Supports dom elements interpolation.
*/
export function t<T extends Record<string, any>>(key: string, args?: T|null, instance = i18next): InferResult<T> {
if (!instance.exists(key, args || undefined)) {
const error = new Error(`Missing translation for key: ${key} and language: ${i18next.language}`);
reportError(error);
}
// Don't need to bind `t` function.
return domT(key, args, instance.t);
}
@ -172,12 +172,6 @@ export function makeT(scope: string, instance?: typeof i18next) {
// This will remove all the overloads from the function, but we don't need them.
scopedResolver = (_key: string, _args?: any) => fixedResolver(_key, {defaultValue: _key, ..._args});
}
// If the key has interpolation or we did pass some arguments, make sure that
// the key exists.
if ((args || key.includes("{{")) && !scopedInstance.exists(`${scope}.${key}`, args || undefined)) {
const error = new Error(`Missing translation for key: ${key} and language: ${i18next.language}`);
reportError(error);
}
return domT(key, args, scopedResolver!);
};
}

@ -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,

@ -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[] {

@ -144,7 +144,6 @@
"https-proxy-agent": "5.0.1",
"i18n-iso-countries": "6.1.0",
"i18next": "21.9.1",
"i18next-fs-backend": "1.1.5",
"i18next-http-middleware": "3.2.1",
"image-size": "0.6.3",
"jquery": "3.5.0",

@ -62,6 +62,7 @@
--icon-Filter: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTIuNSw0IEwxLjUsNCBDMS4yMjQsNCAxLDMuNzc2IDEsMy41IEMxLDMuMjI0IDEuMjI0LDMgMS41LDMgTDIuNSwzIEMyLjc3NiwzIDMsMy4yMjQgMywzLjUgQzMsMy43NzYgMi43NzYsNCAyLjUsNCBaIE00LjUsNyBMMS41LDcgQzEuMjI0LDcgMSw2Ljc3NiAxLDYuNSBDMSw2LjIyNCAxLjIyNCw2IDEuNSw2IEw0LjUsNiBDNC43NzYsNiA1LDYuMjI0IDUsNi41IEM1LDYuNzc2IDQuNzc2LDcgNC41LDcgWiBNNi41LDEwIEwxLjUsMTAgQzEuMjI0LDEwIDEsOS43NzYgMSw5LjUgQzEsOS4yMjQgMS4yMjQsOSAxLjUsOSBMNi41LDkgQzYuNzc2LDkgNyw5LjIyNCA3LDkuNSBDNyw5Ljc3NiA2Ljc3NiwxMCA2LjUsMTAgWiBNOC41LDEzIEwxLjUsMTMgQzEuMjI0LDEzIDEsMTIuNzc2IDEsMTIuNSBDMSwxMi4yMjQgMS4yMjQsMTIgMS41LDEyIEw4LjUsMTIgQzguNzc2LDEyIDksMTIuMjI0IDksMTIuNSBDOSwxMi43NzYgOC43NzYsMTMgOC41LDEzIFogTTE0Ljk3Mjg4OCwyLjI4NDkwODg1IEMxNS4wMjY1MzgyLDIuNDU3MzYxNzggMTQuOTk2ODg2NCwyLjY1NTk2NzI1IDE0Ljg5Nzg5MzIsMi43ODc4MDY4MyBMMTEuNjgxMzA2OCw3LjA3NjY2OTY1IEMxMS42MTE3MzQ3LDcuMTY5NTg4MDkgMTEuNTM4MDA5LDcuNDA2ODA3MDIgMTEuNTM4MDA5LDcuNTM4MTg1MDggTDExLjUzODAwOSwxMy41Mzc4ODU2IEMxMS41MzgwMDksMTMuNzI0NjQ1NSAxMS40NTM3ODQsMTMuODkyOTQ0OCAxMS4zMjQzMzE0LDEzLjk2NDE3MiBDMTEuMjgxNTI2NiwxMy45ODgwMTcgMTEuMjM2NTI5NywxMy45OTk0MDEgMTEuMTkxOTk0MywxMy45OTk0MDEgQzExLjEwMTg4NTEsMTMuOTk5NDAxIDExLjAxMzI3NTgsMTMuOTUyNDgwMyAxMC45NDcxNjUsMTMuODY0MTc3IEw5LjU2MjY0NDg2LDEyLjAxODExNTMgQzkuNDk3Njg3NzksMTEuOTMxNjU4MSA5LjQ2MTIyODc2LDExLjgxNDI3OTMgOS40NjEyMjg3NiwxMS42OTE4MjM5IEw5LjQ2MTIyODc2LDcuNTM4MTg1MDggQzkuNDYxMjI4NzYsNy40MDY4MDcwMiA5LjM4NzYxODQ0LDcuMTY5NTg4MDkgOS4zMTc5MzA5Miw3LjA3Njk3NzMzIEw2LjEwMTM0NDUyLDIuNzg3OTYwNjcgQzYuMDAyMzUxMzMsMi42NTU5NjcyNSA1Ljk3MjgxNDksMi40NTc1MTU2MiA2LjAyNjM0OTY4LDIuMjg0OTA4ODUgQzYuMDc5ODg0NDUsMi4xMTIzMDIwOSA2LjIwNjEwNjU0LDIgNi4zNDYwNTg0NSwyIEwxNC42NTMxNzkzLDIgQzE0Ljc5MzI0NjYsMiAxNC45MTk0Njg2LDIuMTEyMzAyMDkgMTQuOTcyODg4LDIuMjg0OTA4ODUgWiIgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIi8+PC9zdmc+');
--icon-FilterSimple: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTkuOTUwODMzMzMsMC4yMiBDOS44NzgyODY2NywwLjA4NDQ5NzcxMDggOS43MzcwMzQsLTYuMTY5Njc5NTFlLTA1IDkuNTgzMzMzMzMsLTQuNjI1OTMyOGUtMTcgTDAuNDE2NjY2NjY3LC00LjYyNTkzMjhlLTE3IEMwLjI2MzEwMzQ1NCw3LjkzMjczNDM1ZS0wNSAwLjEyMjAzMTQsMC4wODQ2MTg0ODc1IDAuMDQ5NTQ5NDAxMiwwLjIxOTk5OTUyOSBDLTAuMDIyOTMyNTk3OCwwLjM1NTM4MDU3MSAtMC4wMTUwNzQwNTE3LDAuNTE5NjU2MDYyIDAuMDcsMC42NDc1IEwzLjMzMzMzMzMzLDUuNTQyNSBMMy4zMzMzMzMzMyw5LjU4MzMzMzMzIEMzLjMzMzMzMzMzLDkuODEzNDUyIDMuNTE5ODgxMzYsMTAgMy43NSwxMCBMNi4yNSwxMCBDNi40ODAxMTg2NCwxMCA2LjY2NjY2NjY3LDkuODEzNDUyIDYuNjY2NjY2NjcsOS41ODMzMzMzMyBMNi42NjY2NjY2Nyw1LjU0MjUgTDkuOTMsMC42NDc1IEMxMC4wMTUxOTU2LDAuNTE5NzI3OTkxIDEwLjAyMzIwMTMsMC4zNTU0NTA1NDcgOS45NTA4MzMzMywwLjIyIFoiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDMgMykiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg==');
--icon-Fireworks: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTguNjY2NDcgMTUuMzMzM0g3LjMzMzE0QzcuMzMzMTQgOS44NDc5NyA0LjQ0NzggOC42MzA2MyA0LjQxODQ3IDguNjE5M0wzLjc5OTggOC4zNzEzIDQuMjk1MTQgNy4xMzMzIDQuOTE0NDcgNy4zODA2M0M1LjAyNTE0IDcuNDI1OTcgNi45NTE4IDguMjM1MyA3Ljk5OTggMTEuMTE0IDkuMDQ3OCA4LjIzNTk3IDEwLjk3NDUgNy40MjY2MyAxMS4wODUxIDcuMzgwNjNMMTEuNzA0NSA3LjEzMzMgMTIuMTk5OCA4LjM3MTMgMTEuNTg0NSA4LjYxNzNDMTEuNDM5OCA4LjY3OTk3IDguNjY2NDcgOS45NDE5NyA4LjY2NjQ3IDE1LjMzMzN6TTEwLjQ1MzEgMS43OTA1NEw4Ljc1NzcyIDEuNTQ0NTQgNy45OTk3Mi4wMDg1NDQ5MiA3LjI0MTcyIDEuNTQ0NTQgNS41NDYzOSAxLjc5MDU0IDYuNzczMDUgMi45ODY1NCA2LjQ4MzcyIDQuNjc1MjEgNy45OTk3MiAzLjg3Nzg4IDkuNTE1NzIgNC42NzUyMSA5LjIyNjM5IDIuOTg2NTQgMTAuNDUzMSAxLjc5MDU0ek01LjI5OTE3IDEyLjM1OEwzLjY5ODUgMTIuMTI1MyAyLjk4MzE3IDEwLjY3NTMgMi4yNjcxNyAxMi4xMjUzLjY2NjUwNCAxMi4zNTggMS44MjQ1IDEzLjQ4NjYgMS41NTExNyAxNS4wODA2IDIuOTgzMTcgMTQuMzI4NiA0LjQxNDUgMTUuMDgwNiA0LjE0MTE3IDEzLjQ4NjYgNS4yOTkxNyAxMi4zNTh6TTE1LjMzMzMgMTIuMzU4TDEzLjczMjcgMTIuMTI1MyAxMy4wMTY3IDEwLjY3NTMgMTIuMzAxMyAxMi4xMjUzIDEwLjcwMDcgMTIuMzU4IDExLjg1ODcgMTMuNDg2NiAxMS41ODUzIDE1LjA4MDYgMTMuMDE2NyAxNC4zMjg2IDE0LjQ0ODcgMTUuMDgwNiAxNC4xNzUzIDEzLjQ4NjYgMTUuMzMzMyAxMi4zNTh6IiBmaWxsPSIjZmZmIi8+PHBhdGggZD0iTTguNjY2MzQgNy41MzMxM1Y1LjM0MThINy4zMzMwMVY3LjUyMDQ2QzcuNTc2MjEgNy43NjgxMSA3LjgwMjUgOC4wMzE4MSA4LjAxMDM0IDguMzA5OCA4LjIxMjIgOC4wMzcxNyA4LjQzMTMyIDcuNzc3NzUgOC42NjYzNCA3LjUzMzEzek0xLjY2NjUgNy4zMzMyNUMyLjIxODc5IDcuMzMzMjUgMi42NjY1IDYuODg1NTQgMi42NjY1IDYuMzMzMjUgMi42NjY1IDUuNzgwOTcgMi4yMTg3OSA1LjMzMzI1IDEuNjY2NSA1LjMzMzI1IDEuMTE0MjIgNS4zMzMyNS42NjY1MDQgNS43ODA5Ny42NjY1MDQgNi4zMzMyNS42NjY1MDQgNi44ODU1NCAxLjExNDIyIDcuMzMzMjUgMS42NjY1IDcuMzMzMjV6TTE0LjMzMyA3LjMzMzI1QzE0Ljg4NTMgNy4zMzMyNSAxNS4zMzMgNi44ODU1NCAxNS4zMzMgNi4zMzMyNSAxNS4zMzMgNS43ODA5NyAxNC44ODUzIDUuMzMzMjUgMTQuMzMzIDUuMzMzMjUgMTMuNzgwNyA1LjMzMzI1IDEzLjMzMyA1Ljc4MDk3IDEzLjMzMyA2LjMzMzI1IDEzLjMzMyA2Ljg4NTU0IDEzLjc4MDcgNy4zMzMyNSAxNC4zMzMgNy4zMzMyNXoiIGZpbGw9IiNmZmYiLz48L3N2Zz4=');
--icon-Flag: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSJjdXJyZW50Q29sb3IiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBjbGFzcz0iZmVhdGhlciBmZWF0aGVyLWZsYWciPjxwYXRoIGQ9Ik00IDE1czEtMSA0LTEgNSAyIDggMiA0LTEgNC0xVjNzLTEgMS00IDEtNS0yLTgtMi00IDEtNCAxek00IDIyTDQgMTUiLz48L3N2Zz4=');
--icon-Folder: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTMsNSBMMTMsNSBDMTMuNTUyMjg0Nyw1IDE0LDUuNDQ3NzE1MjUgMTQsNiBMMTQsMTMgQzE0LDEzLjU1MjI4NDcgMTMuNTUyMjg0NywxNCAxMywxNCBMMywxNCBDMi40NDc3MTUyNSwxNCAyLDEzLjU1MjI4NDcgMiwxMyBMMiw2IEMyLDUuNDQ3NzE1MjUgMi40NDc3MTUyNSw1IDMsNSBaIE00LDIgTDEyLDIgTDEyLDQgTDQsNCBMNCwyIFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg==');
--icon-FontBold: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZD0iTTMuMTk1MDk4OCAxLjgzNTQxOTloNS42OTAzODE4Yy43NTQ1OTUyIDAgMS40NzgyNzI0LjI5OTc1NjcgMi4wMTE4NTM0LjgzMzMzNzUuNTMzNTgxLjUzMzU4MDguODMzMzM3IDEuMjU3MjU4Mi44MzMzMzcgMi4wMTE4NTM1IDAgLjc1NDU5NTItLjI5OTc1NiAxLjQ3ODI3MjctLjgzMzMzNyAyLjAxMTg1MzVDMTAuMzYzNzUzIDcuMjI2MDQ1MSA5LjY0MDA3NTggNy41MjU4MDE4IDguODg1NDgwNiA3LjUyNTgwMThINS4wOTE4OTI3TTUuMDkxODkyNyA3LjUyNTgwMThoNS4yMTYxODMzYy44ODAzNTIgMCAxLjcyNDY1My4zNDk3MTgyIDIuMzQ3MTY5Ljk3MjIyMDcuNjIyNTAyLjYyMjUxNTIuOTcyMjIgMS40NjY4MTYxLjk3MjIyIDIuMzQ3MTY4NXYwYzAgLjg4MDM1My0uMzQ5NzE4IDEuNzI0NjU0LS45NzIyMiAyLjM0NzIxOS0uNjIyNTE2LjYyMjQwMi0xLjQ2NjgxNy45NzIxNy0yLjM0NzE2OS45NzIxN0gzLjE5NTA5ODhNNS4wOTE4OTI3IDEuODM1NDE5OVYxNC4xNjQ1OCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDMwMDAwIiBzdHJva2Utd2lkdGg9IjEuMjY1IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48L3N2Zz4=');
--icon-FontItalic: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PGRlZnM+PGNsaXBQYXRoIGlkPSJhIj48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMCAwSDEyVjEySDB6Ii8+PC9jbGlwUGF0aD48L2RlZnM+PGcgdHJhbnNmb3JtPSJtYXRyaXgoMS4xMzU2IDAgMCAxLjEzNTYgLjk4MyAxLjM5KSIgY2xpcC1wYXRoPSJ1cmwoI2EpIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGZpbGw9Im5vbmUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCI+PHBhdGggZD0ibSA0Ljg3NSwwLjM3NSBoIDQuNSIgc3Ryb2tlPSIjMDAwIi8+PHBhdGggZD0ibSAyLjYyNSwxMS42MjUgaCA0LjUiIHN0cm9rZT0iIzA2MDAwMCIvPjxwYXRoIGQ9Im0gNy4xMjUsMC4zNzUgLTIuMjUsMTEuMjUiIHN0cm9rZT0iIzAwMCIgc3Ryb2tlLW9wYWNpdHk9Ii45OSIvPjwvZz48L3N2Zz4=');
@ -74,7 +75,7 @@
--icon-Idea: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTcsMC41IEM3LDAuMjIzODU3NjI1IDcuMjIzODU3NjMsMCA3LjUsMCBDNy43NzYxNDIzNywwIDgsMC4yMjM4NTc2MjUgOCwwLjUgTDgsMi41IEM4LDIuNzc2MTQyMzcgNy43NzYxNDIzNywzIDcuNSwzIEM3LjIyMzg1NzYzLDMgNywyLjc3NjE0MjM3IDcsMi41IEw3LDAuNSBaIE0xLjQ4OTQ0NjYxLDMuMTk2NTUzMzkgQzEuMjk0MTg0NDYsMy4wMDEyOTEyNCAxLjI5NDE4NDQ2LDIuNjg0NzA4NzYgMS40ODk0NDY2MSwyLjQ4OTQ0NjYxIEMxLjY4NDcwODc2LDIuMjk0MTg0NDYgMi4wMDEyOTEyNCwyLjI5NDE4NDQ2IDIuMTk2NTUzMzksMi40ODk0NDY2MSBMMy42MTA1NTMzOSwzLjkwMzQ0NjYxIEMzLjgwNTgxNTU0LDQuMDk4NzA4NzYgMy44MDU4MTU1NCw0LjQxNTI5MTI0IDMuNjEwNTUzMzksNC42MTA1NTMzOSBDMy40MTUyOTEyNCw0LjgwNTgxNTU0IDMuMDk4NzA4NzYsNC44MDU4MTU1NCAyLjkwMzQ0NjYxLDQuNjEwNTUzMzkgTDEuNDg5NDQ2NjEsMy4xOTY1NTMzOSBaIE0xMi44MDM0NDY2LDIuNDg5NDQ2NjEgQzEyLjk5ODcwODgsMi4yOTQxODQ0NiAxMy4zMTUyOTEyLDIuMjk0MTg0NDYgMTMuNTEwNTUzNCwyLjQ4OTQ0NjYxIEMxMy43MDU4MTU1LDIuNjg0NzA4NzYgMTMuNzA1ODE1NSwzLjAwMTI5MTI0IDEzLjUxMDU1MzQsMy4xOTY1NTMzOSBMMTIuMDk2NTUzNCw0LjYxMDU1MzM5IEMxMS45MDEyOTEyLDQuODA1ODE1NTQgMTEuNTg0NzA4OCw0LjgwNTgxNTU0IDExLjM4OTQ0NjYsNC42MTA1NTMzOSBDMTEuMTk0MTg0NSw0LjQxNTI5MTI0IDExLjE5NDE4NDUsNC4wOTg3MDg3NiAxMS4zODk0NDY2LDMuOTAzNDQ2NjEgTDEyLjgwMzQ0NjYsMi40ODk0NDY2MSBaIE0xMCwxMi4yMjMzNjY2IEwxMCwxNC41IEMxMCwxNC43NzYxNDI0IDkuNzc2MTQyMzcsMTUgOS41LDE1IEw1LjUsMTUgQzUuMjIzODU3NjMsMTUgNSwxNC43NzYxNDI0IDUsMTQuNSBMNSwxMi4yMjI2NzMyIEMzLjI4NDM3MTQ2LDExLjA3Nzg0MDQgMi41NTY2OTc1Niw4Ljg5NTgwMTU4IDMuMjczODk0MTMsNi45MzUwNzcwNyBDNC4wMjUwMDkzMyw0Ljg4MTYyMzMxIDYuMTQzNDksMy42NjUyNjIzMiA4LjI5NTU2NjkyLDQuMDUxNzk5OTQgQzEwLjQ0NzQzMTksNC40MzgyOTk0OSAxMi4wMTAxMTM2LDYuMzE1NTI0NzggMTEuOTk5OTk3Myw4LjUwMTczODM2IEMxMS45OTY0OTg2LDEwLjAwNDU1NjkgMTEuMjQwNzkxOSwxMS4zOTg0NzI3IDEwLDEyLjIyMzM2NjYgWiBNOSwxMS45NDUgQzksMTEuNzY1OTAwOCA5LjA5NTc5MzU2LDExLjYwMDQ4MDUgOS4yNTExMzEyOCwxMS41MTEzMzYxIEMxMC4zMzA1NDU2LDEwLjg5MTg4NzYgMTAuOTk3MjgyNCw5Ljc0MzQzMzQ2IDExLjAwMDAwMTIsOC40OTg5MDc2OSBDMTEuMDA4MTE1Nyw2Ljc5NzAxMjQgOS43OTI2MjE5OCw1LjMzNjY5MDI0IDguMTE4Nzg0MzgsNS4wMzYwNDk4OCBDNi40NDQ5NDY3OCw0LjczNTQwOTUxIDQuNzk3MjM5NTksNS42ODE0NjgwNSA0LjIxMzAzODg4LDcuMjc4NTk4NzUgQzMuNjI4ODM4MTYsOC44NzU3Mjk0NSA0LjI3NzIzNDU1LDEwLjY2MTY2IDUuNzUwMDA1NjksMTEuNTExOTkwNiBDNS45MDQ3MDMwMiwxMS42MDEzMDc4IDYsMTEuNzY2MzY5NiA2LDExLjk0NSBMNiwxNCBMOSwxNCBMOSwxMS45NDUgWiBNMTMuNSw5IEMxMy4yMjM4NTc2LDkgMTMsOC43NzYxNDIzNyAxMyw4LjUgQzEzLDguMjIzODU3NjMgMTMuMjIzODU3Niw4IDEzLjUsOCBMMTQuNSw4IEMxNC43NzYxNDI0LDggMTUsOC4yMjM4NTc2MyAxNSw4LjUgQzE1LDguNzc2MTQyMzcgMTQuNzc2MTQyNCw5IDE0LjUsOSBMMTMuNSw5IFogTTAuNSw5IEMwLjIyMzg1NzYyNSw5IDAsOC43NzYxNDIzNyAwLDguNSBDMCw4LjIyMzg1NzYzIDAuMjIzODU3NjI1LDggMC41LDggTDEuNSw4IEMxLjc3NjE0MjM3LDggMiw4LjIyMzg1NzYzIDIsOC41IEMyLDguNzc2MTQyMzcgMS43NzYxNDIzNyw5IDEuNSw5IEwwLjUsOSBaIiBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9Im5vbnplcm8iLz48L3N2Zz4=');
--icon-Import: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTExLjUsNyBDMTEuOTI5NTc5NCw3IDEyLjE1OTE2ODQsNy41MDU5NjA4NiAxMS44NzYyODgzLDcuODI5MjUyMyBMOC4zNzYyODgzNSwxMS44MjkyNTIzIEM4LjE3NzA4MjcsMTIuMDU2OTE1OSA3LjgyMjkxNzMsMTIuMDU2OTE1OSA3LjYyMzcxMTY1LDExLjgyOTI1MjMgTDQuMTIzNzExNjUsNy44MjkyNTIzIEMzLjg0MDgzMTY0LDcuNTA1OTYwODYgNC4wNzA0MjA2LDcgNC41LDcgTDYsNyBMNiwxLjUgQzYsMS4yMjM4NTc2MyA2LjIyMzg1NzYzLDEgNi41LDEgTDkuNSwxIEM5Ljc3NjE0MjM3LDEgMTAsMS4yMjM4NTc2MyAxMCwxLjUgTDEwLDcgTDExLjUsNyBaIE04LDEwLjc0MDcwMzkgTDEwLjM5ODExNTksOCBMOS41LDggQzkuMjIzODU3NjMsOCA5LDcuNzc2MTQyMzcgOSw3LjUgTDksMiBMNywyIEw3LDcuNSBDNyw3Ljc3NjE0MjM3IDYuNzc2MTQyMzcsOCA2LjUsOCBMNS42MDE4ODQxMSw4IEw4LDEwLjc0MDcwMzkgWiBNMTIuNSwyIEMxMi4yMjM4NTc2LDIgMTIsMS43NzYxNDIzNyAxMiwxLjUgQzEyLDEuMjIzODU3NjMgMTIuMjIzODU3NiwxIDEyLjUsMSBMMTQuNSwxIEMxNS4zMjgxNDI0LDEgMTYsMS42NzE4NTc2MyAxNiwyLjUgTDE2LDEzLjUgQzE2LDE0LjMyODE0MjQgMTUuMzI4MTQyNCwxNSAxNC41LDE1IEwxLjUsMTUgQzAuNjcxODU3NjI1LDE1IDAsMTQuMzI4MTQyNCAwLDEzLjUgTDAsMi41IEMwLDEuNjcxODU3NjMgMC42NzE4NTc2MjUsMSAxLjUsMSBMMy41LDEgQzMuNzc2MTQyMzcsMSA0LDEuMjIzODU3NjMgNCwxLjUgQzQsMS43NzYxNDIzNyAzLjc3NjE0MjM3LDIgMy41LDIgTDEuNSwyIEMxLjIyNDE0MjM3LDIgMSwyLjIyNDE0MjM3IDEsMi41IEwxLDEzLjUgQzEsMTMuNzc1ODU3NiAxLjIyNDE0MjM3LDE0IDEuNSwxNCBMMTQuNSwxNCBDMTQuNzc1ODU3NiwxNCAxNSwxMy43NzU4NTc2IDE1LDEzLjUgTDE1LDIuNSBDMTUsMi4yMjQxNDIzNyAxNC43NzU4NTc2LDIgMTQuNSwyIEwxMi41LDIgWiIgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIi8+PC9zdmc+');
--icon-ImportArrow: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTciIGhlaWdodD0iMzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiPjxkZWZzPjxwYXRoIGlkPSJhIiBkPSJNMCAwSDE1VjMySDB6Ii8+PC9kZWZzPjxnIGZpbGw9Im5vbmUiIGZpbGwtcnVsZT0iZXZlbm9kZCI+PGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTEgMSkiPjxtYXNrIGlkPSJiIiBmaWxsPSIjZmZmIj48dXNlIHhsaW5rOmhyZWY9IiNhIi8+PC9tYXNrPjxjaXJjbGUgc3Ryb2tlPSIjRDlEOUQ5IiBzdHJva2Utd2lkdGg9IjQiIG1hc2s9InVybCgjYikiIGN4PSIxNSIgY3k9IjE2IiByPSIxMiIvPjwvZz48cGF0aCBzdHJva2U9IiNEOUQ5RDkiIGZpbGw9IiNEOUQ5RDkiIHRyYW5zZm9ybT0icm90YXRlKDkwIDE0IDQpIiBkPSJNMTUgMUwxOSA3IDExIDd6Ii8+PC9nPjwvc3ZnPg==');
--icon-Info: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMTYiIHdpZHRoPSIxNiI+PHBhdGggZD0iTSAxNiw4IEEgOCw4IDAgMSAxIDAsOCA4LDggMCAwIDEgMTYsOCBaIE0gOSw0IEEgMSwxIDAgMSAxIDcsNCAxLDEgMCAwIDEgOSw0IFogTSA3LDcgYSAxLDEgMCAwIDAgMCwyIHYgMyBhIDEsMSAwIDAgMCAxLDEgSCA5IEEgMSwxIDAgMSAwIDksMTEgViA4IEEgMSwxIDAgMCAwIDgsNyBaIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvc3ZnPg==');
--icon-Info: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZD0iTTgsMGE4LDgsMCwxLDAsOCw4QTguMDI0LDguMDI0LDAsMCwwLDgsMFptLjUsMTNoLTFhLjUuNSwwLDAsMS0uNS0uNXYtNUEuNS41LDAsMCwxLDcuNSw3aDFhLjUuNSwwLDAsMSwuNS41djVBLjUuNSwwLDAsMSw4LjUsMTNaTTgsNUEuOTQ1Ljk0NSwwLDAsMSw3LDQsLjk0NS45NDUsMCwwLDEsOCwzLC45NDUuOTQ1LDAsMCwxLDksNCwuOTQ1Ljk0NSwwLDAsMSw4LDVaIiBmaWxsPSIjNGI4MDU2Ii8+PC9zdmc+');
--icon-LeftAlign: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTIuNSw4LjUgQzIuMjIzODU3NjMsOC41IDIsOC4yNzYxNDIzNyAyLDggQzIsNy43MjM4NTc2MyAyLjIyMzg1NzYzLDcuNSAyLjUsNy41IEwxMy41LDcuNSBDMTMuNzc2MTQyNCw3LjUgMTQsNy43MjM4NTc2MyAxNCw4IEMxNCw4LjI3NjE0MjM3IDEzLjc3NjE0MjQsOC41IDEzLjUsOC41IEwyLjUsOC41IFogTTIuNSw0IEMyLjIyMzg1NzYzLDQgMiwzLjc3NjE0MjM3IDIsMy41IEMyLDMuMjIzODU3NjMgMi4yMjM4NTc2MywzIDIuNSwzIEwxMy41LDMgQzEzLjc3NjE0MjQsMyAxNCwzLjIyMzg1NzYzIDE0LDMuNSBDMTQsMy43NzYxNDIzNyAxMy43NzYxNDI0LDQgMTMuNSw0IEwyLjUsNCBaIE0yLjUsMTMgQzIuMjIzODU3NjMsMTMgMiwxMi43NzYxNDI0IDIsMTIuNSBDMiwxMi4yMjM4NTc2IDIuMjIzODU3NjMsMTIgMi41LDEyIEw3LjUsMTIgQzcuNzc2MTQyMzcsMTIgOCwxMi4yMjM4NTc2IDgsMTIuNSBDOCwxMi43NzYxNDI0IDcuNzc2MTQyMzcsMTMgNy41LDEzIEwyLjUsMTMgWiIgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIi8+PC9zdmc+');
--icon-Lock: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIj48cGF0aCBkPSJNMTIgNkwxMCA2IDEwIDRDMTAgMi44OTU0MzA1IDkuMTA0NTY5NSAyIDggMiA2Ljg5NTQzMDUgMiA2IDIuODk1NDMwNSA2IDRMNiA2IDQgNiA0IDRDNCAxLjc5MDg2MSA1Ljc5MDg2MSAwIDggMCAxMC4yMDkxMzkgMCAxMiAxLjc5MDg2MSAxMiA0TDEyIDZ6TTE0IDdMMiA3QzEuNDQ3NzE1MjUgNyAxIDcuNDQ3NzE1MjUgMSA4TDEgMTVDMSAxNS41NTIyODQ3IDEuNDQ3NzE1MjUgMTYgMiAxNkwxNCAxNkMxNC41NTIyODQ3IDE2IDE1IDE1LjU1MjI4NDcgMTUgMTVMMTUgOEMxNSA3LjQ0NzcxNTI1IDE0LjU1MjI4NDcgNyAxNCA3ek04IDEzQzYuODk1NDMwNSAxMyA2IDEyLjEwNDU2OTUgNiAxMSA2IDkuODk1NDMwNSA2Ljg5NTQzMDUgOSA4IDkgOS4xMDQ1Njk1IDkgMTAgOS44OTU0MzA1IDEwIDExIDEwIDEyLjEwNDU2OTUgOS4xMDQ1Njk1IDEzIDggMTN6Ii8+PC9nPjwvc3ZnPg==');
--icon-Log: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTcuNSw4LjUgTDYuNSw4LjUgQzYuMjIzODU3NjMsOC41IDYsOC4yNzYxNDIzNyA2LDggQzYsNy43MjM4NTc2MyA2LjIyMzg1NzYzLDcuNSA2LjUsNy41IEw3LjUsNy41IEw3LjUsMC41IEM3LjUsMC4yMjM4NTc2MjUgNy43MjM4NTc2MywwIDgsMCBDOC4yNzYxNDIzNywwIDguNSwwLjIyMzg1NzYyNSA4LjUsMC41IEw4LjUsMyBMOS41LDMgQzkuNzc2MTQyMzcsMyAxMCwzLjIyMzg1NzYzIDEwLDMuNSBDMTAsMy43NzYxNDIzNyA5Ljc3NjE0MjM3LDQgOS41LDQgTDguNSw0IEw4LjUsMTIgTDkuNSwxMiBDOS43NzYxNDIzNywxMiAxMCwxMi4yMjM4NTc2IDEwLDEyLjUgQzEwLDEyLjc3NjE0MjQgOS43NzYxNDIzNywxMyA5LjUsMTMgTDguNSwxMyBMOC41LDE1LjUgQzguNSwxNS43NzYxNDI0IDguMjc2MTQyMzcsMTYgOCwxNiBDNy43MjM4NTc2MywxNiA3LjUsMTUuNzc2MTQyNCA3LjUsMTUuNSBMNy41LDguNSBaIE0xMiwyIEwxMiw1IEwxNSw1IEwxNSwyIEwxMiwyIFogTTExLjUsMSBMMTUuNSwxIEMxNS43NzYxNDI0LDEgMTYsMS4yMjM4NTc2MyAxNiwxLjUgTDE2LDUuNSBDMTYsNS43NzYxNDIzNyAxNS43NzYxNDI0LDYgMTUuNSw2IEwxMS41LDYgQzExLjIyMzg1NzYsNiAxMSw1Ljc3NjE0MjM3IDExLDUuNSBMMTEsMS41IEMxMSwxLjIyMzg1NzYzIDExLjIyMzg1NzYsMSAxMS41LDEgWiBNMTIsMTEgTDEyLDE0IEwxNSwxNCBMMTUsMTEgTDEyLDExIFogTTExLjUsMTAgTDE1LjUsMTAgQzE1Ljc3NjE0MjQsMTAgMTYsMTAuMjIzODU3NiAxNiwxMC41IEwxNiwxNC41IEMxNiwxNC43NzYxNDI0IDE1Ljc3NjE0MjQsMTUgMTUuNSwxNSBMMTEuNSwxNSBDMTEuMjIzODU3NiwxNSAxMSwxNC43NzYxNDI0IDExLDE0LjUgTDExLDEwLjUgQzExLDEwLjIyMzg1NzYgMTEuMjIzODU3NiwxMCAxMS41LDEwIFogTTQsOS41IEw0LDYuNSBMMSw2LjUgTDEsOS41IEw0LDkuNSBaIE00LjUsMTAuNSBMMC41LDEwLjUgQzAuMjIzODU3NjI1LDEwLjUgMCwxMC4yNzYxNDI0IDAsMTAgTDguODgxNzg0MmUtMTYsNiBDOC44ODE3ODQyZS0xNiw1LjcyMzg1NzYzIDAuMjIzODU3NjI1LDUuNSAwLjUsNS41IEw0LjUsNS41IEM0Ljc3NjE0MjM3LDUuNSA1LDUuNzIzODU3NjMgNSw2IEw1LDEwIEM1LDEwLjI3NjE0MjQgNC43NzYxNDIzNywxMC41IDQuNSwxMC41IFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg==');

@ -0,0 +1,39 @@
<svg width="16" height="12" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="a" maskUnits="userSpaceOnUse" x="0" y="0" width="16" height="12">
<path fill="#fff" d="M0 0h16v12H0z"/>
</mask>
<g mask="url(#a)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 0v12h16V0H0z" fill="#093"/>
<mask id="b" maskUnits="userSpaceOnUse" x="0" y="0" width="16" height="12">
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 0v12h16V0H0z" fill="#fff"/>
</mask>
<g mask="url(#b)">
<g filter="url(#BR_-_Brazil__filter0_d)" fill-rule="evenodd" clip-rule="evenodd">
<path d="M7.963 1.852l6.101 4.252-6.184 3.982L1.904 6.02l6.06-4.169z" fill="#FFD221"/>
<path d="M7.963 1.852l6.101 4.252-6.184 3.982L1.904 6.02l6.06-4.169z" fill="url(#BR_-_Brazil__paint0_linear)"/>
</g>
<path fill-rule="evenodd" clip-rule="evenodd" d="M8 8.6a2.5 2.5 0 100-5 2.5 2.5 0 000 5z" fill="#2E42A5"/>
<mask id="c" maskUnits="userSpaceOnUse" x="5" y="3" width="6" height="6">
<path fill-rule="evenodd" clip-rule="evenodd" d="M8 8.6a2.5 2.5 0 100-5 2.5 2.5 0 000 5z" fill="#fff"/>
</mask>
<g mask="url(#c)" fill="#F7FCFF">
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.19 7.285l-.112.059.022-.125-.09-.088.124-.018L7.19 7l.056.113.125.018-.09.088.02.125-.111-.059zm1 0l-.112.059.022-.125-.09-.088.124-.018L8.19 7l.056.113.125.018-.09.088.02.125-.111-.059zm0 .6l-.112.059.022-.125-.09-.088.124-.018.056-.113.056.113.125.018-.09.088.02.125-.111-.059zm-.5-2.1l-.112.059.022-.125-.09-.088.124-.018.056-.113.056.113.125.018-.09.088.02.125-.111-.059zm0 1l-.112.059.022-.125-.09-.088.124-.018.056-.113.056.113.125.018-.09.088.02.125-.111-.059zm-.7-.5l-.112.059.022-.125-.09-.088.124-.018L6.99 6l.056.113.125.018-.09.088.02.125-.11-.059zm-.7.4l-.112.059.022-.125-.09-.088.124-.018.056-.113.056.113.125.018-.09.088.02.125-.111-.059zm2.3-1.7l-.112.059.022-.125-.09-.088.124-.018.056-.113.056.113.125.018-.09.088.02.125-.111-.059z"/>
<path d="M4.962 5.499l.076-.998c2.399.181 4.292.97 5.656 2.373l-.717.697C8.795 6.355 7.131 5.662 4.962 5.5z"/>
</g>
</g>
</g>
<defs>
<linearGradient id="BR_-_Brazil__paint0_linear" x1="16" y1="12" x2="16" y2="0" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFC600"/>
<stop offset="1" stop-color="#FFDE42"/>
</linearGradient>
<filter id="BR_-_Brazil__filter0_d" x="1.904" y="1.852" width="12.16" height="8.234" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
<feOffset/>
<feColorMatrix values="0 0 0 0 0.0313726 0 0 0 0 0.368627 0 0 0 0 0 0 0 0 0.28 0"/>
<feBlend in2="BackgroundImageFix" result="effect1_dropShadow"/>
<feBlend in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

@ -0,0 +1,10 @@
<svg width="16" height="12" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="a" maskUnits="userSpaceOnUse" x="0" y="0" width="16" height="12">
<rect width="16" height="12" rx="-1" fill="#fff"/>
</mask>
<g mask="url(#a)" fill-rule="evenodd" clip-rule="evenodd">
<path d="M0 8h16v4H0V8z" fill="#FFD018"/>
<path d="M0 4h16v4H0V4z" fill="#E31D1C"/>
<path d="M0 0h16v4H0V0z" fill="#272727"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 432 B

@ -0,0 +1,46 @@
<svg width="16" height="12" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="a" maskUnits="userSpaceOnUse" x="0" y="0" width="16" height="12">
<path fill="#fff" d="M0 0h16v12H0z"/>
</mask>
<g mask="url(#a)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 0v12h16V0H0z" fill="#FFB400"/>
<mask id="b" maskUnits="userSpaceOnUse" x="0" y="0" width="16" height="12">
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 0v12h16V0H0z" fill="#fff"/>
</mask>
<g mask="url(#b)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 0v3h16V0H0zm0 9v3h16V9H0z" fill="#C51918"/>
<path fill="#F1F9FF" d="M2.504 5.136h.56v2.912h-.56z"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.288 4.632H2.28v.28h.168v.224h.672v-.224h.168v-.28zM3.12 8.216h.168v.28H2.28v-.28h.168v-.224h.672v.224z" fill="#C88A02"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M2.784 4.688c.122 0 .134-.046.206-.114.056-.054.186-.12.186-.194 0-.17-.175-.308-.392-.308-.216 0-.392.138-.392.308 0 .083.09.138.157.194.072.058.124.114.235.114z" fill="#AD1619"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M2.28 8.496h1.008v.448s-.126-.056-.252-.056-.252.056-.252.056-.126-.056-.252-.056-.252.056-.252.056v-.448z" fill="#005BBF"/>
<path fill="#F1F9FF" d="M7.992 5.136h.56v2.912h-.56z"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.776 4.632H7.768v.28h.168v.224h.672v-.224h.168v-.28zm-.168 3.584h.168v.28H7.768v-.28h.168v-.224h.672v.224z" fill="#C88A02"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.272 4.688c.122 0 .134-.046.206-.114.056-.054.186-.12.186-.194 0-.17-.175-.308-.392-.308-.216 0-.392.138-.392.308 0 .083.09.138.157.194.072.058.124.114.235.114z" fill="#AD1619"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.768 8.496h1.008v.448s-.126-.056-.252-.056-.252.056-.252.056-.126-.056-.252-.056-.252.056-.252.056v-.448z" fill="#005BBF"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M2.494 7.84c.101-.122.157-.234.157-.352a.316.316 0 00-.06-.192l.006-.003s.11-.048.15-.067c.072-.034.135-.07.197-.116.04-.028.092-.06.173-.103l.096-.051.096-.053a.62.62 0 00.183-.144.268.268 0 00-.061-.399.728.728 0 00-.301-.096l-.197-.03a4.931 4.931 0 01-.177-.03c.345-.057.836-.036 1.052.076l.206-.398c-.44-.228-1.445-.204-1.82.054-.275.19-.238.476.048.6.12.05.276.085.564.131a1.431 1.431 0 00-.126.081.799.799 0 01-.127.075 6.71 6.71 0 01-.125.055l-.017.008c-.233.106-.346.252-.312.517l.018.143.033.01.344.284zm-.288-.37v.002-.002zm6.147.018c0 .118.056.23.157.352l.344-.284.033-.01.018-.143c.034-.265-.079-.411-.312-.517l-.016-.008a6.704 6.704 0 01-.125-.055.8.8 0 01-.128-.075 1.431 1.431 0 00-.126-.08c.289-.047.445-.081.564-.133.286-.123.323-.41.048-.6-.375-.257-1.379-.28-1.82-.053l.206.398c.216-.112.708-.133 1.052-.075l-.177.029-.196.03a.728.728 0 00-.301.096.268.268 0 00-.062.4.605.605 0 00.183.143l.096.053.096.05c.081.044.134.076.173.104.062.045.126.082.198.116.039.02.15.068.15.067l.006.003a.316.316 0 00-.061.192z" fill="#AD1619"/>
<path d="M2.962 6.2l.165.034v.247c-.176.14-.623.377-.623.377V6.2h.458zm5.195 0l-.165.034v.247c.176.14.623.377.623.377V6.2h-.458z" fill="#F1F9FF"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.388 3.603v-.082a1.53 1.53 0 00-.905-.31 1.806 1.806 0 00-.918-.156v-.001c-.465-.046-.934.157-.934.157-.473 0-.905.31-.905.31v.082l.565.567s.159.546 1.272.418v.001s.737-.02.79-.037l.063-.02c.144-.042.31-.09.407-.362l.565-.567zm-1.825-.519z" fill="#AD1619"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.76 3.7l.038-.042.513.483c.097-.026.626-.16 1.216-.168h.045a5.281 5.281 0 011.232.172s-.006.053-.021.116l.056-.11.006-.006.517-.487.038.041-.514.483c-.075.138-.112.23-.112.267 0 .058-.15.092-.444.128-.23.027-.5.046-.722.048h-.056a7.222 7.222 0 01-.722-.048c-.294-.036-.444-.07-.444-.128a.118.118 0 00-.004-.027.44.44 0 01-.064-.154 2.84 2.84 0 00-.044-.086L3.76 3.7zm2.867.75c.039.019.07 0 .095-.035a.18.18 0 00-.004.027.541.541 0 01-.098.032 3.068 3.068 0 01-.296.047c-.238.029-.52.047-.744.049a7.095 7.095 0 01-.744-.049 3.067 3.067 0 01-.296-.047.633.633 0 01-.073-.02l.006-.003c.122-.058.93-.111 1.077-.12.145.009.954.062 1.077.12zm.154-.01l-.001.001z" fill="#C88A02"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.556 2.674a.168.168 0 100-.336.168.168 0 000 .336z" fill="#005BBF"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.493 2.038h.117v.074h.076v.117H5.61v.233h.076v.117h-.27v-.117h.077v-.233h-.077v-.117h.077v-.074z" fill="#C88A02"/>
<path fill="#C88A02" d="M5.472 2.672h.224V3.4h-.224z"/>
<path d="M3.854 3.648l-.308-.011c.145-.839.86-1.25 2.002-1.25 1.144 0 1.856.413 1.99 1.255l-.415.043c-.066-.41-.752-.78-1.569-.78-.818 0-1.629.33-1.7.743z" fill="#C88A02"/>
<path opacity=".3" fill-rule="evenodd" clip-rule="evenodd" d="M3.736 4.632h3.64v3.27S7.106 9 5.556 9s-1.82-1.127-1.82-1.127V4.632z" fill="#E1E5E8"/>
<mask id="c" maskUnits="userSpaceOnUse" x="3" y="4" width="5" height="5">
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.736 4.632h3.64v3.27S7.106 9 5.556 9s-1.82-1.127-1.82-1.127V4.632z" fill="#fff"/>
</mask>
<g mask="url(#c)">
<path fill="#FFC034" d="M3.736 6.648h1.848v2.184H3.736z"/>
<path fill="#AD1619" d="M3.736 4.576h1.848v2.128H3.736z"/>
<path fill="#AD1619" d="M5.528 6.592h1.848V8.72H5.528z"/>
<path fill="#F1F9FF" d="M5.528 4.632h1.96v2.072h-1.96z"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.741 8.721s-1.186.094-1.186-.671c0 0-.011.671-1.25.671v.604h2.436v-.604z" fill="#F1F9FF"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.556 7.096c.232 0 .42-.2.42-.448 0-.247-.188-.448-.42-.448-.232 0-.42.2-.42.448 0 .247.188.448.42.448z" fill="#005BBF" stroke="#AD1619" stroke-width=".583"/>
</g>
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.5 6a.5.5 0 100-1 .5.5 0 000 1z" fill="#C88A02"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.5 6a.5.5 0 100-1 .5.5 0 000 1z" fill="#C37C9C"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.5 8a.5.5 0 100-1 .5.5 0 000 1z" fill="#FFC034"/>
<path d="M4.5 8a.5.5 0 100-1 .5.5 0 000 1z" fill="#AD1619"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 6.1 KiB

@ -0,0 +1,10 @@
<svg width="16" height="12" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="a" maskUnits="userSpaceOnUse" x="0" y="0" width="16" height="12">
<path fill="#fff" d="M0 0h16v12H0z"/>
</mask>
<g mask="url(#a)" fill-rule="evenodd" clip-rule="evenodd">
<path d="M11 0h5v12h-5V0z" fill="#F50100"/>
<path d="M0 0h6v12H0V0z" fill="#2E42A5"/>
<path d="M5 0h6v12H5V0z" fill="#F7FCFF"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 421 B

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021 Yummygum (https://github.com/Yummygum/flagpack-core)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

@ -0,0 +1,14 @@
<svg width="16" height="12" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="a" maskUnits="userSpaceOnUse" x="0" y="0" width="16" height="12">
<path fill="#fff" d="M0 0h16v12H0z"/>
</mask>
<g mask="url(#a)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 0v12h16V0H0z" fill="#E31D1C"/>
<mask id="b" maskUnits="userSpaceOnUse" x="0" y="0" width="16" height="12">
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 0v12h16V0H0z" fill="#fff"/>
</mask>
<g mask="url(#b)">
<path d="M5-.5h-.5v5h-5v3h5v5h3v-5h9v-3h-9v-5H5z" fill="#2E42A5" stroke="#F7FCFF"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 628 B

@ -0,0 +1,11 @@
<svg width="16" height="12" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="a" maskUnits="userSpaceOnUse" x="0" y="0" width="16" height="12">
<path fill="#fff" d="M0 0h16v12H0z"/>
</mask>
<g mask="url(#a)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 0h16v12H0V0z" fill="#E31D1C"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 1v1h16V1H0zm0 2v1h16V3H0zm0 3V5h16v1H0zm0 1v1h16V7H0zm0 3V9h16v1H0zm0 2v-1h16v1H0z" fill="#F7FCFF"/>
<path fill="#2E42A5" d="M0 0h9v7H0z"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M1.04 2.174l.53-.37.411.297h-.233l.471.416-.159.584h-.249l-.242-.536-.206.536H.748l.471.416-.179.657.53-.37.411.297h-.233l.471.416-.159.584h-.249l-.242-.536-.206.536H.748l.471.416-.179.657.53-.37.513.37-.16-.657.412-.416h-.19l.425-.296.411.296h-.233l.471.416-.179.657.53-.37.513.37-.16-.657.412-.416h-.19l.425-.296.411.296h-.233l.471.416-.179.657.53-.37.513.37-.16-.657.412-.416h-.19l.425-.296.411.296h-.233l.471.416-.179.657.53-.37.513.37-.16-.657.412-.416h-.524l-.242-.536-.206.536h-.298l-.142-.584.412-.416h-.19l.425-.296.513.369-.16-.657.412-.416h-.524l-.242-.536-.206.536h-.298l-.142-.584.412-.416h-.19l.425-.296.513.369-.16-.657.412-.416h-.524L7.569.565l-.206.536h-.615l.471.416-.159.584h-.249l-.242-.536-.206.536h-.298l-.142-.584.412-.416h-.524L5.569.565l-.206.536h-.615l.471.416-.159.584h-.249l-.242-.536-.206.536h-.298l-.142-.584.412-.416h-.524L3.569.565l-.206.536h-.615l.471.416-.159.584h-.249l-.242-.536-.206.536h-.298l-.142-.584.412-.416h-.524L1.569.565l-.206.536H.748l.471.416-.179.657zM7.06 4.1l.159-.584-.47-.416h.232l-.411-.296-.425.296h.19l-.412.416.142.584h.298l.206-.536.242.536h.249zm-1.079 0l-.411-.296-.425.296h.19l-.412.416.142.584h.298l.206-.536.242.536h.249l.159-.584-.47-.416h.232zm-1.762.416L4.06 5.1h-.249l-.242-.536-.206.536h-.298l-.142-.584.412-.416h-.19l.425-.296.411.296h-.233l.471.416zm.144-.416h-.298l-.142-.584.412-.416h-.19l.425-.296.411.296h-.233l.471.416-.159.584h-.249l-.242-.536-.206.536zm-1.303 0l.159-.584-.47-.416h.232l-.411-.296-.425.296h.19l-.412.416.142.584h.298l.206-.536.242.536h.249zm3.159-1.584L6.06 3.1h-.249l-.242-.536-.206.536h-.298l-.142-.584.412-.416h-.19l.425-.296.411.296h-.233l.471.416zM3.981 2.1l-.411-.296-.425.296h.19l-.412.416.142.584h.298l.206-.536.242.536h.249l.159-.584-.47-.416h.232z" fill="#F7FCFF"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

@ -39,7 +39,8 @@
"Users": "Benutzer",
"View As": "Anzeigen als",
"Seed rules": "Saatgut-Regeln",
"When adding table rules, automatically add a rule to grant OWNER full access.": "Beim Hinzufügen von Tabellenregeln wird automatisch eine Regel hinzugefügt, um BESITZER vollen Zugriff zu gewähren."
"When adding table rules, automatically add a rule to grant OWNER full access.": "Beim Hinzufügen von Tabellenregeln wird automatisch eine Regel hinzugefügt, um BESITZER vollen Zugriff zu gewähren.",
"Permission to edit document structure": "Berechtigung zur Bearbeitung der Dokumentenstruktur"
},
"AccountPage": {
"API": "API",
@ -274,7 +275,10 @@
"Save": "Speichern",
"Save and Reload": "Speichern und neu laden",
"This document's ID (for API use):": "Die ID dieses Dokuments (für API-Verwendung):",
"Time Zone:": "Zeitzone:"
"Time Zone:": "Zeitzone:",
"API": "API",
"Document ID copied to clipboard": "Dokument-ID in die Zwischenablage kopiert",
"Ok": "Ok"
},
"DocumentUsage": {
"Attachments Size": "Anhänge Größe",
@ -649,7 +653,8 @@
"Return to viewing as yourself": "Zurück zur Selbstdarstellung",
"TOOLS": "WERKZEUGE",
"Tour of this Document": "Tour durch dieses Dokument",
"Validate Data": "Daten validieren"
"Validate Data": "Daten validieren",
"Settings": "Einstellungen"
},
"TopBar": {
"Manage Team": "Team verwalten"
@ -670,8 +675,8 @@
"Apply": "Anwenden",
"Cancel": "Abbrechen",
"Preview": "Vorschau",
"Revise": "Überarbeiten",
"Update formula (Shift+Enter)": "Formel aktualisieren (Umschalttaste+Eingabetaste)"
"Update formula (Shift+Enter)": "Formel aktualisieren (Umschalttaste+Eingabetaste)",
"Revise": "Überarbeiten"
},
"UserManagerModel": {
"Editor": "Redakteur",
@ -834,11 +839,125 @@
"Users from table": "Benutzer aus der Tabelle",
"View As": "Anzeigen als"
},
"TypeTransform": {
"Apply": "Anwenden",
"TypeTransformation": {
"Update formula (Shift+Enter)": "Formel aktualisieren (Umschalttaste+Eingabetaste)",
"Cancel": "Abbrechen",
"Revise": "Überarbeiten",
"Preview": "Vorschau",
"Update formula (Shift+Enter)": "Formel aktualisieren (Umschalttaste+Eingabetaste)",
"Revise": "Überarbeiten"
"Apply": "Anwenden"
},
"CellStyle": {
"CELL STYLE": "ZELLENSTIL",
"Open row styles": "Zeilenstile öffnen",
"Cell Style": "Zellenstil",
"Default cell style": "Standard-Zellenstil",
"Mixed style": "Gemischter Stil"
},
"DiscussionEditor": {
"Resolve": "Beschließen",
"Save": "Speichern",
"Show resolved comments": "Gelöste Kommentare anzeigen",
"Only my threads": "Nur meine Fäden",
"Open": "Öffnen",
"Remove": "Entfernen",
"Reply to a comment": "Auf einen Kommentar antworten",
"Reply": "Antwort",
"Comment": "Kommentar",
"Edit": "Bearbeiten",
"Only current page": "Nur aktuelle Seite",
"Started discussion": "Begonnene Diskussion",
"Write a comment": "Schreiben Sie einen Kommentar",
"Marked as resolved": "Markiert als gelöst",
"Cancel": "Abbrechen",
"Showing last {{nb}} comments": "Letzte {{nb}} Kommentare anzeigen"
},
"ColumnInfo": {
"COLUMN DESCRIPTION": "SPALTENBESCHREIBUNG",
"COLUMN ID: ": "SPALTEN-ID: ",
"Save": "Speichern",
"COLUMN LABEL": "SPALTENBEZEICHNUNG",
"Cancel": "Abbrechen"
},
"ChoiceTextBox": {
"CHOICES": "WAHLEN"
},
"ColumnEditor": {
"COLUMN DESCRIPTION": "SPALTENBESCHREIBUNG",
"COLUMN LABEL": "SPALTENBEZEICHNUNG"
},
"ConditionalStyle": {
"Add conditional style": "Bedingten Stil hinzufügen",
"Error in style rule": "Fehler in der Stilregel",
"Rule must return True or False": "Regel muss wahr oder falsch zurückgeben",
"Add another rule": "Eine weitere Regel hinzufügen",
"Row Style": "Zeilenstil"
},
"CurrencyPicker": {
"Invalid currency": "Ungültige Währung"
},
"FieldBuilder": {
"Changing multiple column types": "Mehrere Spaltentypen ändern",
"Mixed format": "Gemischtes Format",
"Mixed types": "Gemischte Typen",
"Revert field settings for {{colId}} to common": "Feldeinstellungen für {{colId}} auf \"Allgemein\" zurücksetzen",
"Apply Formula to Data": "Formel auf Daten anwenden",
"CELL FORMAT": "ZELLENFORMAT",
"DATA FROM TABLE": "DATEN AUS TABELLE",
"Save field settings for {{colId}} as common": "Feldeinstellungen für {{colId}} als Algemein speichern",
"Use separate field settings for {{colId}}": "Verwenden Sie separate Feldeinstellungen für {{colId}}"
},
"EditorTooltip": {
"Convert column to formula": "Spalte in Formel umwandeln"
},
"FormulaEditor": {
"Column or field is required": "Spalte oder Feld ist erforderlich",
"Error in the cell": "Fehler in der Zelle",
"Errors in all {{numErrors}} cells": "Fehler in allen {{numErrors}} Zellen",
"editingFormula is required": "Bearbeitungsformel ist erforderlich",
"Errors in {{numErrors}} of {{numCells}} cells": "Fehler in {{numErrors}} von {{numCells}} Zellen"
},
"Reference": {
"SHOW COLUMN": "SPALTE ANZEIGEN",
"CELL FORMAT": "ZELLENFORMAT",
"Row ID": "Zeilen-ID"
},
"HyperLinkEditor": {
"[link label] url": "[Linklabel]-URL"
},
"welcomeTour": {
"Add New": "Neu hinzufügen",
"Building up": "Zubauend",
"Configuring your document": "Konfigurieren Ihres Dokuments",
"Help Center": "Hilfe-Center",
"Sharing": "Teilen",
"Welcome to Grist!": "Willkommen bei Grist!",
"Enter": "Eingabe",
"Customizing columns": "Anpassen von Spalten",
"Browse our {{templateLibrary}} to discover what's possible and get inspired.": "Durchsuchen Sie unsere {{templateLibrary}}, um zu entdecken, was möglich ist und sich inspirieren lassen.",
"Double-click or hit {{enter}} on a cell to edit it. ": "Doppelklicken oder {{enter}} auf eine Zelle drücken, um sie zu bearbeiten. ",
"Editing Data": "Bearbeiten von Daten",
"Flying higher": "Höher fliegen",
"Share": "Teilen",
"Reference": "Referenz",
"Make it relational! Use the {{ref}} type to link tables. ": "Machen Sie es relativ! Verwenden Sie den {{ref}}-Typ, um Tabellen zu verlinken. ",
"Start with {{equal}} to enter a formula.": "Beginnen Sie mit {{equal}}, um eine Formel einzugeben.",
"Set formatting options, formulas, or column types, such as dates, choices, or attachments. ": "Legen Sie Formatierungsoptionen, Formeln oder Spaltentypen fest, z. B. Daten, Auswahlmöglichkeiten oder Anhänge. ",
"Toggle the {{creatorPanel}} to format columns, ": "Schalten Sie die {{creatorPanel}} an, um Spalten zu formatieren, ",
"Use the Share button ({{share}}) to share the document or export data.": "Verwenden Sie die Schaltfläche Teilen ({{share}}), um das Dokument zu teilen oder Daten zu exportieren.",
"Use {{addNew}} to add widgets, pages, or import more data. ": "Verwenden Sie {{addNew}}, um Widgets und Seiten hinzuzufügen oder weitere Daten zu importieren. ",
"Use {{helpCenter}} for documentation or questions.": "Verwenden Sie {{helpCenter}} für Dokumentation oder Fragen.",
"convert to card view, select data, and more.": "in die Kartenansicht konvertieren, Daten auswählen und vieles mehr.",
"creator panel": "Ersteller-Panel",
"template library": "Vorlagenbibliothek"
},
"NumericTextBox": {
"Currency": "Währung",
"Decimals": "Dezimalstellen",
"Default currency ({{defaultCurrency}})": "Standardwährung ({{defaultCurrency}})",
"Number Format": "Zahlenformat"
},
"FieldEditor": {
"It should be impossible to save a plain data value into a formula column": "Es sollte unmöglich sein, einen einfachen Datenwert in eine Formelspalte zu speichern",
"Unable to finish saving edited cell": "Speichern der bearbeiteten Zelle kann nicht abgeschlossen werden"
}
}

@ -56,7 +56,8 @@
"Save": "Save",
"Theme": "Theme",
"Two-factor authentication": "Two-factor authentication",
"Two-factor authentication is an extra layer of security for your Grist account designed to ensure that you're the only person who can access your account, even if someone knows your password.": "Two-factor authentication is an extra layer of security for your Grist account designed to ensure that you're the only person who can access your account, even if someone knows your password."
"Two-factor authentication is an extra layer of security for your Grist account designed to ensure that you're the only person who can access your account, even if someone knows your password.": "Two-factor authentication is an extra layer of security for your Grist account designed to ensure that you're the only person who can access your account, even if someone knows your password.",
"Language": "Language"
},
"AccountWidget": {
"Access Details": "Access Details",
@ -896,5 +897,8 @@
"convert to card view, select data, and more.": "convert to card view, select data, and more.",
"creator panel": "creator panel",
"template library": "template library"
},
"LanguageMenu": {
"Language": "Language"
}
}

@ -34,7 +34,8 @@
"Special Rules": "Reglas especiales",
"View As": "Ver como",
"Seed rules": "Reglas de semillas",
"When adding table rules, automatically add a rule to grant OWNER full access.": "Al agregar reglas de tabla, agregue automáticamente una regla para otorgar acceso completo al PROPIETARIO."
"When adding table rules, automatically add a rule to grant OWNER full access.": "Al agregar reglas de tabla, agregue automáticamente una regla para otorgar acceso completo al PROPIETARIO.",
"Permission to edit document structure": "Permiso para editar la estructura del documento"
},
"AccountPage": {
"API": "API",
@ -223,7 +224,10 @@
"Save": "Guardar",
"Save and Reload": "Guardar y recargar",
"This document's ID (for API use):": "ID de este documento (para uso de API):",
"Time Zone:": "Zona horaria:"
"Time Zone:": "Zona horaria:",
"Ok": "Ok",
"Document ID copied to clipboard": "ID de documento copiado al portapapeles",
"API": "API"
},
"DuplicateTable": {
"Copy all data in addition to the table structure.": "Copiar todos los datos además de la estructura de la tabla.",
@ -543,7 +547,8 @@
"Return to viewing as yourself": "Volver a ver como usted mismo",
"TOOLS": "HERRAMIENTAS",
"Tour of this Document": "Recorrido por este documento",
"Validate Data": "Validar datos"
"Validate Data": "Validar datos",
"Settings": "Ajustes"
},
"TopBar": {
"Manage Team": "Administrar equipo"
@ -771,9 +776,9 @@
"TypeTransform": {
"Apply": "Aplicar",
"Cancel": "Cancelar",
"Preview": "Vista previa",
"Revise": "Revisar",
"Update formula (Shift+Enter)": "Actualizar fórmula (Mayús+Intro)"
"Update formula (Shift+Enter)": "Actualizar fórmula (Mayús+Intro)",
"Preview": "Vista previa"
},
"UserManagerModel": {
"Editor": "Editor",
@ -824,11 +829,125 @@
"View As": "Ver como",
"Example Users": "Usuarios de ejemplo"
},
"TypeTransform": {
"Apply": "Aplicar",
"TypeTransformation": {
"Update formula (Shift+Enter)": "Actualizar fórmula (Mayús+Intro)",
"Cancel": "Cancelar",
"Revise": "Revisar",
"Update formula (Shift+Enter)": "Actualizar fórmula (Mayús+Intro)",
"Apply": "Aplicar",
"Preview": "Vista previa"
},
"CellStyle": {
"Cell Style": "Estilo de celda",
"Default cell style": "Estilo de celda predeterminado",
"CELL STYLE": "ESTILO DE CELDA",
"Open row styles": "Abrir estilos de fila",
"Mixed style": "Estilo mixto"
},
"ColumnInfo": {
"Cancel": "Cancelar",
"Save": "Guardar",
"COLUMN DESCRIPTION": "DESCRIPCIÓN DE LA COLUMNA",
"COLUMN ID: ": "ID DE COLUMNA: ",
"COLUMN LABEL": "ETIQUETA DE COLUMNA"
},
"FieldBuilder": {
"Revert field settings for {{colId}} to common": "Revertir la configuración de campo de {{colId}} a común",
"Use separate field settings for {{colId}}": "Utilice configuraciones de campo separadas para {{colId}}",
"Save field settings for {{colId}} as common": "Guardar configuración de campo de {{colId}} como común",
"DATA FROM TABLE": "DATOS DE LA TABLA",
"Mixed types": "Tipos mixtos",
"Apply Formula to Data": "Aplicar Fórmula a Datos",
"CELL FORMAT": "FORMATO DE CELDA",
"Changing multiple column types": "Cambiar varios tipos de columna",
"Mixed format": "Formato mixto"
},
"CurrencyPicker": {
"Invalid currency": "Moneda inválida"
},
"DiscussionEditor": {
"Cancel": "Cancelar",
"Edit": "Editar",
"Only current page": "Sólo página actual",
"Remove": "Quitar",
"Resolve": "Resolver",
"Save": "Guardar",
"Started discussion": "Discusión iniciada",
"Show resolved comments": "Mostrar comentarios resueltos",
"Write a comment": "Escribe un comentario",
"Marked as resolved": "Marcado como resuelto",
"Only my threads": "Sólo mis hilos",
"Reply": "Respuesta",
"Comment": "Comentario",
"Open": "Abrir",
"Reply to a comment": "Responder a un comentario",
"Showing last {{nb}} comments": "Mostrando los últimos {{nb}} comentarios"
},
"EditorTooltip": {
"Convert column to formula": "Convertir columna en fórmula"
},
"FormulaEditor": {
"Errors in {{numErrors}} of {{numCells}} cells": "Errores en {{numErrors}} de {{numCells}} celdas",
"Column or field is required": "Se requiere columna o campo",
"Error in the cell": "Error en la celda",
"Errors in all {{numErrors}} cells": "Errores en todas las {{numErrors}} celdas",
"editingFormula is required": "ediciónFórmula es necesaria"
},
"welcomeTour": {
"Add New": "Agregar Nuevo",
"Configuring your document": "Configurando tu documento",
"Enter": "Intro",
"Flying higher": "Volando más alto",
"Reference": "Referencia",
"Start with {{equal}} to enter a formula.": "Comience con {{equal}} para introducir una fórmula.",
"Toggle the {{creatorPanel}} to format columns, ": "Active {{creatorPanel}} para dar formato a las columnas, ",
"Welcome to Grist!": "¡Bienvenido a Grist!",
"template library": "biblioteca de plantillas",
"creator panel": "panel creador",
"Sharing": "Compartiendo",
"Help Center": "Centro de ayuda",
"Building up": "Construyendo",
"Customizing columns": "Personalizando columnas",
"Double-click or hit {{enter}} on a cell to edit it. ": "Haga doble clic o pulse {{enter}} en una celda para editarla. ",
"Browse our {{templateLibrary}} to discover what's possible and get inspired.": "Explore nuestro {{templateLibrary}} para descubrir lo que es posible y sentirse inspirado.",
"Editing Data": "Editando datos",
"Make it relational! Use the {{ref}} type to link tables. ": "¡Hazlo relacional! Utilice el tipo {{ref}} para vincular tablas. ",
"Set formatting options, formulas, or column types, such as dates, choices, or attachments. ": "Establezca opciones de formato, fórmulas o tipos de columnas, como fechas, opciones o anexos. ",
"Share": "Compartir",
"Use the Share button ({{share}}) to share the document or export data.": "Utilice el botón Compartir ({{share}}) para compartir el documento o exportar los datos.",
"Use {{helpCenter}} for documentation or questions.": "Utilice {{helpCenter}} para documentación o preguntas.",
"Use {{addNew}} to add widgets, pages, or import more data. ": "Utilice {{addNew}} para añadir widgets, páginas o importar más datos. ",
"convert to card view, select data, and more.": "convertir a la vista de la tarjeta, seleccionar datos y más."
},
"HyperLinkEditor": {
"[link label] url": "[etiqueta de enlace] url"
},
"NumericTextBox": {
"Currency": "Moneda",
"Default currency ({{defaultCurrency}})": "Moneda predeterminada ({{defaultCurrency}})",
"Number Format": "Formato de número",
"Decimals": "Decimales"
},
"ChoiceTextBox": {
"CHOICES": "OPCIONES"
},
"ColumnEditor": {
"COLUMN DESCRIPTION": "DESCRIPCIÓN DE LA COLUMNA",
"COLUMN LABEL": "ETIQUETA DE COLUMNA"
},
"FieldEditor": {
"Unable to finish saving edited cell": "No se puede terminar de guardar la celda editada",
"It should be impossible to save a plain data value into a formula column": "Debería ser imposible guardar un valor de datos plano en una columna de fórmulas"
},
"ConditionalStyle": {
"Add another rule": "Añadir otra regla",
"Error in style rule": "Error en la regla de estilo",
"Rule must return True or False": "La regla debe regresar Verdadera o Falsa",
"Add conditional style": "Añadir estilo condicional",
"Row Style": "Estilo de fila"
},
"Reference": {
"SHOW COLUMN": "MOSTRAR COLUMNA",
"CELL FORMAT": "FORMATO DE CELDA",
"Row ID": "ID de fila"
}
}

@ -39,7 +39,8 @@
"Users": "Usuários",
"View As": "Ver como",
"Seed rules": "Regras de propagação",
"When adding table rules, automatically add a rule to grant OWNER full access.": "Ao adicionar regras de tabela, adicione automaticamente uma regra para conceder ao PROPRIETÁRIO acesso total."
"When adding table rules, automatically add a rule to grant OWNER full access.": "Ao adicionar regras de tabela, adicione automaticamente uma regra para conceder ao PROPRIETÁRIO acesso total.",
"Permission to edit document structure": "Permissão para editar a estrutura do documento"
},
"AccountPage": {
"API": "API",
@ -274,7 +275,10 @@
"Save": "Salvar",
"Save and Reload": "Salvar e Recarregar",
"This document's ID (for API use):": "O ID deste documento (para uso em API):",
"Time Zone:": "Fuso horário:"
"Time Zone:": "Fuso horário:",
"Ok": "Ok",
"Document ID copied to clipboard": "ID do documento copiado para a área de transferência",
"API": "API"
},
"DocumentUsage": {
"Attachments Size": "Tamanho dos Anexos",
@ -649,7 +653,8 @@
"Return to viewing as yourself": "Voltar a ver como você mesmo",
"TOOLS": "FERRAMENTAS",
"Tour of this Document": "Tour desse Documento",
"Validate Data": "Validar dados"
"Validate Data": "Validar dados",
"Settings": "Configurações"
},
"TopBar": {
"Manage Team": "Gerenciar Equipe"
@ -834,11 +839,125 @@
"View As": "Ver como",
"Example Users": "Usuários de exemplo"
},
"TypeTransform": {
"Apply": "Aplicar",
"welcomeTour": {
"Use {{helpCenter}} for documentation or questions.": "Use {{helpCenter}} para documentação ou perguntas.",
"convert to card view, select data, and more.": "converta para a visualização de cartão, selecione dados e muito mais.",
"Start with {{equal}} to enter a formula.": "Comece com {{equal}} para inserir uma fórmula.",
"Welcome to Grist!": "Bem-vindo ao Grist!",
"template library": "biblioteca de modelos",
"Flying higher": "Voando mais alto",
"Add New": "Adicionar Novo",
"Building up": "Construindo",
"Customizing columns": "Personalizando colunas",
"Configuring your document": "Configurando seu documento",
"Make it relational! Use the {{ref}} type to link tables. ": "Faça-o relacional! Use o tipo {{ref}} para vincular tabelas. ",
"Browse our {{templateLibrary}} to discover what's possible and get inspired.": "Procure nosso {{templateLibrary}} para descobrir o que é possível e se inspirar.",
"Double-click or hit {{enter}} on a cell to edit it. ": "Clique duas vezes ou pressione {{enter}} em uma célula para editá-la. ",
"Editing Data": "Editando dados",
"Enter": "Entra",
"Help Center": "Centro de Ajuda",
"Reference": "Referência",
"Set formatting options, formulas, or column types, such as dates, choices, or attachments. ": "Defina opções de formatação, fórmulas ou tipos de coluna, como datas, escolhas ou anexos. ",
"Share": "Compartilhar",
"Sharing": "Compartilhando",
"Toggle the {{creatorPanel}} to format columns, ": "Alternar o {{creatorPanel}} para formatar colunas, ",
"Use {{addNew}} to add widgets, pages, or import more data. ": "Use {{addNew}} para adicionar widgets, páginas ou importar mais dados. ",
"creator panel": "painel do criador",
"Use the Share button ({{share}}) to share the document or export data.": "Use o botão Compartilhar ({{share}}) para compartilhar o documento ou exportar dados."
},
"ColumnInfo": {
"COLUMN LABEL": "RÓTULO DA COLUNA",
"Cancel": "Cancelar",
"Preview": "Pré-visualização",
"Save": "Salvar",
"COLUMN DESCRIPTION": "DESCRIÇÃO DA COLUNA",
"COLUMN ID: ": "ID DA COLUNA: "
},
"ConditionalStyle": {
"Row Style": "Estilo de Linha",
"Rule must return True or False": "A regra deve retornar Verdadeiro ou Falso",
"Error in style rule": "Erro na regra de estilo",
"Add another rule": "Adicionar outra regra",
"Add conditional style": "Adicionar estilo condicional"
},
"CurrencyPicker": {
"Invalid currency": "Moeda inválida"
},
"DiscussionEditor": {
"Cancel": "Cancelar",
"Marked as resolved": "Marcado como resolvido",
"Reply": "Responder",
"Reply to a comment": "Responder a um comentário",
"Write a comment": "Escreva um comentário",
"Comment": "Comentário",
"Resolve": "Resolver",
"Started discussion": "Discussão iniciada",
"Edit": "Editar",
"Only current page": "Somente a página atual",
"Only my threads": "Somente meus tópicos",
"Showing last {{nb}} comments": "Mostrar os últimos {{nb}} comentários",
"Open": "Abrir",
"Remove": "Remover",
"Save": "Salvar",
"Show resolved comments": "Mostrar comentários resolvidos"
},
"FieldBuilder": {
"CELL FORMAT": "FORMATO DA CÉLULA",
"DATA FROM TABLE": "DADOS DA TABELA",
"Apply Formula to Data": "Aplicar fórmula aos dados",
"Changing multiple column types": "Alterar vários tipos de colunas",
"Mixed format": "Formato misto",
"Save field settings for {{colId}} as common": "Salvar configurações de campo da {{colId}} como comum",
"Revert field settings for {{colId}} to common": "Reverter configurações de campo da {{colId}} para comum",
"Mixed types": "Tipos mistos",
"Use separate field settings for {{colId}}": "Use configurações de campo separadas para {{colId}}"
},
"FormulaEditor": {
"Column or field is required": "Coluna ou campo é obrigatório",
"editingFormula is required": "ediçãoFórmula é obrigatório",
"Errors in all {{numErrors}} cells": "Erro em todas as {{numErrors}} células",
"Errors in {{numErrors}} of {{numCells}} cells": "Erros em {{numErrors}} de {{numCells}} células",
"Error in the cell": "Erro na célula"
},
"HyperLinkEditor": {
"[link label] url": "[rótulo do link] url"
},
"NumericTextBox": {
"Currency": "Moeda",
"Decimals": "Decimais",
"Default currency ({{defaultCurrency}})": "Moeda padrão ({{defaultCurrency}})",
"Number Format": "Formato de número"
},
"TypeTransformation": {
"Preview": "Prévisualizar",
"Apply": "Aplicar",
"Revise": "Revisar",
"Cancel": "Cancelar",
"Update formula (Shift+Enter)": "Atualizar a fórmula (Shift+Enter)"
},
"CellStyle": {
"CELL STYLE": "ESTILO DE CÉLULA",
"Cell Style": "Estilo de célula",
"Default cell style": "Estilo de célula padrão",
"Mixed style": "Estilo misto",
"Open row styles": "Estilos de linha aberta"
},
"ColumnEditor": {
"COLUMN DESCRIPTION": "DESCRIÇÃO DA COLUNA",
"COLUMN LABEL": "RÓTULO DA COLUNA"
},
"FieldEditor": {
"Unable to finish saving edited cell": "Não é possível concluir o salvamento da célula editada",
"It should be impossible to save a plain data value into a formula column": "Deveria ser impossível salvar um valor de dados simples em uma coluna de fórmula"
},
"EditorTooltip": {
"Convert column to formula": "Converter coluna em fórmula"
},
"Reference": {
"Row ID": "ID da linha",
"CELL FORMAT": "FORMATO DA CÉLULA",
"SHOW COLUMN": "MOSTRAR COLUNA"
},
"ChoiceTextBox": {
"CHOICES": "ESCOLHAS"
}
}

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-flag"><path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"></path><line x1="4" y1="22" x2="4" y2="15"></line></svg>

After

Width:  |  Height:  |  Size: 334 B

@ -1,18 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
id="svg8"
version="1.1"
viewBox="0 0 16 16"
height="16px"
width="16px">
<defs
id="defs821" />
<title
id="title895">Icons / UI / InfoSolid</title>
<path
style="clip-rule:evenodd;fill-rule:evenodd"
id="path816"
d="M 16,8 A 8,8 0 1 1 0,8 8,8 0 0 1 16,8 Z M 9,4 A 1,1 0 1 1 7,4 1,1 0 0 1 9,4 Z M 7,7 a 1,1 0 0 0 0,2 v 3 a 1,1 0 0 0 1,1 H 9 A 1,1 0 1 0 9,11 V 8 A 1,1 0 0 0 8,7 Z" />
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><title>c-info</title><g fill="#4b8056"><path d="M8,0a8,8,0,1,0,8,8A8.024,8.024,0,0,0,8,0Zm.5,13h-1a.5.5,0,0,1-.5-.5v-5A.5.5,0,0,1,7.5,7h1a.5.5,0,0,1,.5.5v5A.5.5,0,0,1,8.5,13ZM8,5A.945.945,0,0,1,7,4,.945.945,0,0,1,8,3,.945.945,0,0,1,9,4,.945.945,0,0,1,8,5Z" fill="#4b8056"></path></g></svg>

Before

Width:  |  Height:  |  Size: 572 B

After

Width:  |  Height:  |  Size: 373 B

@ -0,0 +1,256 @@
import {assert, createDriver, driver, WebDriver} from 'mocha-webdriver';
import * as gu from 'test/nbrowser/gristUtils';
import {server, setupTestSuite} from 'test/nbrowser/testUtils';
describe("LanguageSettings", function() {
this.timeout('50s');
const cleanup = setupTestSuite();
before(async function() {
if (server.isExternalServer()) {
this.skip();
}
});
// List of languages that chrome supports https://developer.chrome.com/docs/webstore/i18n/#localeTable
const locales = [ // [language to set in the browser, country code detected, language name detected]
['fr', 'FR', 'Français'],
['te', 'US', 'English'], // Telugu is not supported yet, so Grist should fallback to English (US).
['en', 'US', 'English'], // This is a default language for Grist.
['pt-BR', 'BR', 'Português (Brasil)']
];
for (const [locale, countryCode, language] of locales) {
describe(`correctly detects browser language ${locale}`, () => {
// Change the language to the one we want to test.
withLang(locale);
before(async function() {
const session = await gu.session().personalSite.anon.login();
await session.loadRelPath("/");
await gu.waitForDocMenuToLoad();
});
it("shows correct language from browser settings", async () => {
// Find the button to switch the language.
const button = await langButton();
assert.isTrue(await button.isDisplayed());
// Make sure correct flag is shown.
const flag = await button.find("div").getCssValue("background-image");
assert.isTrue(flag.endsWith(countryCode + '.svg")'), `Flag is ${flag} search for ${countryCode}`);
// Make sure we see the all languages in the menu.
await button.click();
const menu = await gu.currentDriver().findWait(".grist-floating-menu", 100);
const allLangues = (await menu.findAll("li", e => e.getText())).map(l => l.toLowerCase());
for (const [, , language] of locales) {
assert.include(allLangues, language.toLowerCase());
}
// Make sure that this language is selected.
assert.equal(await selectedLang(), language.toLowerCase());
// Smoke test that we see the correct language.
const welcomeText = await gu.currentDriver().find(".test-welcome-title").getText();
if (locale === 'en') {
assert.equal(welcomeText, "Welcome to Grist!");
} else if (locale === 'fr') {
assert.equal(welcomeText, "Bienvenue sur Grist !");
}
});
});
}
describe("for Anonymous", function() {
before(async function() {
const session = await gu.session().personalSite.anon.login();
await session.loadRelPath("/");
await gu.waitForDocMenuToLoad();
});
it("allows anonymous user to switch a language", async () => {
await langButton().click();
// By default we have English (US) selected.
assert.equal(await selectedLang(), "english");
// Change to French.
await gu.currentDriver().find(".test-language-lang-fr").click();
// We will be reloaded, so wait until we see the new language.
await waitForLangButton("fr");
// Now we have a cookie with the language selected, so reloading the page should keep it.
await gu.currentDriver().navigate().refresh();
await gu.waitForDocMenuToLoad();
await waitForLangButton("fr");
assert.equal(await languageInCookie(), "fr");
// Switch to German.
await langButton().click();
await gu.currentDriver().find(".test-language-lang-de").click();
await waitForLangButton("de");
// Make sure we see new cookie.
assert.equal(await languageInCookie(), "de");
// Remove the cookie and reload.
await clearCookie();
await gu.currentDriver().navigate().refresh();
await gu.waitForDocMenuToLoad();
// Make sure we see the default language.
await waitForLangButton("en");
// Test if changing the cookie is reflected in the UI. This cookie is available for javascript.
await setCookie("fr");
await gu.currentDriver().navigate().refresh();
await gu.waitForDocMenuToLoad();
await waitForLangButton("fr");
assert.equal(await languageInCookie(), "fr");
// Go back to English.
await clearCookie();
await gu.currentDriver().navigate().refresh();
await gu.waitForDocMenuToLoad();
});
it("when user is logged in the language is still taken from the cookie", async () => {
await langButton().click();
// By default we have English (US) selected ()
assert.equal(await selectedLang(), "english");
// Now login to the account.
const user = await gu.session().personalSite.user('user1').login();
await user.loadRelPath("/");
await gu.waitForDocMenuToLoad();
// Language should still be english.
await waitForHiddenButton("en");
// And we should not have a cookie.
assert.isNull(await languageInCookie());
// Go back to anonymous.
const anonym = await gu.session().personalSite.anon.login();
await anonym.loadRelPath("/");
await gu.waitForDocMenuToLoad();
// Change language to french.
await langButton().click();
await driver.find(".test-language-lang-fr").click();
await waitForLangButton("fr");
// Login as user.
await user.login();
await anonym.loadRelPath("/");
await gu.waitForDocMenuToLoad();
// Language should still be french.
await waitForHiddenButton("fr");
// But now we should have a cookie (cookie is reused).
assert.equal(await languageInCookie(), 'fr');
await clearCookie();
});
});
describe("for logged in user with nb-NO", function() {
withLang("de");
let session: gu.Session;
before(async function() {
session = await gu.session().login();
await session.loadRelPath("/");
await gu.waitForDocMenuToLoad();
});
after(async function() {
await clearCookie();
const api = session.createHomeApi();
await api.updateUserLocale(null);
});
it("profile page detects correct language", async () => {
const driver = gu.currentDriver();
// Make sure we don't have a cookie yet.
assert.isNull(await languageInCookie());
// Or a saved setting.
let gristConfig: any = await driver.executeScript("return window.gristConfig");
assert.isNull(gristConfig.userLocale);
await gu.openProfileSettingsPage();
// Make sure we see the correct language.
assert.equal(await languageMenu().getText(), "Deutsch");
// Make sure we see hidden indicator.
await waitForHiddenButton("de");
// Change language to nb-.NO
await languageMenu().click();
await driver.findContentWait('.test-select-menu li', 'Norsk bokmål (Norge)', 100).click();
// This is api call and we will be reloaded, so wait for the hidden indicator.
await waitForHiddenButton("nb-NO");
// Now we should have a cookie.
assert.equal(await languageInCookie(), "nb-NO");
// And the gristConfig should have this language.
gristConfig = await driver.executeScript("return window.gristConfig");
assert.equal(gristConfig.userLocale, "nb-NO");
// If we remove the cookie, we should still use the gristConfig.
await clearCookie();
await driver.navigate().refresh();
await waitForHiddenButton("nb-NO");
// If we set a different cookie, we should still use the saved setting.
await setCookie("de");
await driver.navigate().refresh();
await waitForHiddenButton("nb-NO");
// Make sure this works on the document, by adding a new doc and smoke testing the Add New button.
await session.tempNewDoc(cleanup, "Test");
assert.equal(await driver.findWait(".test-dp-add-new", 3000).getText(), "Legg til ny");
});
});
});
function languageMenu() {
return gu.currentDriver().find('.test-account-page-language .test-select-open');
}
async function clearCookie() {
await gu.currentDriver().executeScript(
"document.cookie = 'grist_user_locale=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';");
}
async function setCookie(locale: string) {
await gu.currentDriver().executeScript(
`document.cookie = 'grist_user_locale=${locale}; expires=Thu, 01 Jan 2970 00:00:00 UTC; path=/;';`);
}
async function waitForLangButton(locale: string) {
await gu.waitToPass(async () =>
assert.isTrue(await gu.currentDriver().findWait(`.test-language-current-${locale}`, 1000).isDisplayed()), 4000);
}
async function waitForHiddenButton(locale: string) {
await gu.waitToPass(async () =>
assert.isTrue(await gu.currentDriver().findWait(`input.test-language-current-${locale}`, 1000).isPresent()), 4000);
}
async function languageInCookie(): Promise<string | null> {
const cookie2: string = await gu.currentDriver().executeScript("return document.cookie");
return cookie2.match(/grist_user_locale=([^;]+)/)?.[1] ?? null;
}
function withLang(locale: string) {
let customDriver: WebDriver;
let oldLanguage: string | undefined;
before(async function() {
// On Mac we can't change the language, so skip the test.
if (await gu.isMac()) { return this.skip(); }
oldLanguage = process.env.LANGUAGE;
// How to run chrome with a different language:
// https://developer.chrome.com/docs/extensions/reference/i18n/#how-to-set-browsers-locale
process.env.LANGUAGE = locale;
customDriver = await createDriver({
extraArgs: [
'lang=' + locale,
...(process.env.MOCHA_WEBDRIVER_HEADLESS ? [`headless=chrome`] : [])
]
});
server.setDriver(customDriver);
gu.setDriver(customDriver);
const session = await gu.session().personalSite.anon.login();
await session.loadRelPath("/");
await gu.waitForDocMenuToLoad();
});
after(async function() {
if (await gu.isMac()) { return this.skip(); }
gu.setDriver();
server.setDriver();
await customDriver.quit();
process.env.LANGUAGE = oldLanguage;
});
}
function langButton() {
return gu.currentDriver().findWait(".test-language-button", 500);
}
async function selectedLang() {
const menu = gu.currentDriver().findWait(".grist-floating-menu", 100);
return (await menu.find(".test-language-selected").findClosest("li").getText()).toLowerCase();
}

@ -41,7 +41,7 @@ describe("Localization", function() {
const namespaces: Set<string> = new Set();
for (const file of fs.readdirSync(localeDirectory)) {
if (file.endsWith(".json")) {
const lang = file.split('.')[0];
const lang = file.split('.')[0]?.replace(/_/g, '-');
const ns = file.split('.')[1];
langs.add(lang);
namespaces.add(ns);

@ -35,6 +35,8 @@ namespace gristUtils {
// Allow overriding the global 'driver' to use in gristUtil.
let driver: WebDriver;
export function currentDriver() { return driver; }
// Substitute a custom driver to use with gristUtils functions. Omit argument to restore to default.
export function setDriver(customDriver: WebDriver = driverOrig) { driver = customDriver; }
@ -2470,7 +2472,7 @@ export async function openAccountMenu() {
export async function openProfileSettingsPage() {
await openAccountMenu();
await driver.findContent('.grist-floating-menu a', 'Profile Settings').click();
await driver.find('.grist-floating-menu .test-dm-account-settings').click();
await driver.findWait('.test-account-page-login-method', 5000);
}

@ -4509,11 +4509,6 @@ i18n-iso-countries@6.1.0:
dependencies:
diacritics "1.3.0"
i18next-fs-backend@1.1.5:
version "1.1.5"
resolved "https://registry.npmjs.org/i18next-fs-backend/-/i18next-fs-backend-1.1.5.tgz"
integrity sha512-raTel3EfshiUXxR0gvmIoqp75jhkj8+7R1LjB006VZKPTFBbXyx6TlUVhb8Z9+7ahgpFbcQg1QWVOdf/iNzI5A==
i18next-http-middleware@3.2.1:
version "3.2.1"
resolved "https://registry.npmjs.org/i18next-http-middleware/-/i18next-http-middleware-3.2.1.tgz"

Loading…
Cancel
Save