(core) updates from grist-core

pull/576/head
Paul Fitzpatrick 11 months ago
commit 450472f74c

@ -15,7 +15,7 @@ jobs:
uses: actions/checkout@v2
- name: Docker meta
id: meta
uses: docker/metadata-action@v3
uses: docker/metadata-action@v4
with:
images: |
${{ github.repository_owner }}/grist
@ -23,6 +23,9 @@ jobs:
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
stable
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx

@ -1178,8 +1178,8 @@ Useful for examples and templates, but not for sensitive data.`),
},
SchemaEdit: {
name: t("Permission to edit document structure"),
description: t("Allow editors to edit structure (e.g. modify and delete tables, columns, " +
"layouts), and to write formulas, which give access to all data regardless of read restrictions."),
description: t("Allow editors to edit structure (e.g. modify and delete tables, columns, \
layouts), and to write formulas, which give access to all data regardless of read restrictions."),
availableBits: ['schemaEdit'],
...schemaEditRules.denyEditors,
},
@ -1323,7 +1323,7 @@ class SpecialSchemaObsRuleSet extends SpecialObsRuleSet {
return dom.maybe(
(use) => use(this._body).every(rule => rule.isBuiltInOrEmpty(use)),
() => cssConditionError({style: 'margin-left: 56px; margin-bottom: 8px;'},
"This default should be changed if editors' access is to be limited. ",
t("This default should be changed if editors' access is to be limited. "),
dom('a', {style: 'color: inherit; text-decoration: underline'},
'Dismiss', dom.on('click', () => this._allowEditors('confirm'))),
testId('rule-schema-edit-warning'),

@ -660,8 +660,9 @@ export class ChartConfig extends GrainJSDisposable {
),
dom.domComputed(this._optionsObj.prop('errorBars'), (value: ChartOptions["errorBars"]) =>
value === 'symmetric' ? cssRowHelp(t("Each Y series is followed by a series for the length of error bars.")) :
value === 'separate' ? cssRowHelp(t("Each Y series is followed by two series, for " +
"top and bottom error bars."))
value === 'separate' ? cssRowHelp(
t("Each Y series is followed by two series, for top and bottom error bars.")
)
: null
),
]),

@ -265,10 +265,9 @@ export class DocPageModelImpl extends Disposable implements DocPageModel {
{
explanation: (
isDocOwner
? t("You can try reloading the document, or using recovery mode. " +
"Recovery mode opens the document to be fully accessible to " +
"owners, and inaccessible to others. It also disables " +
"formulas. [{{error}}]", {error: err.message})
? t("You can try reloading the document, or using recovery mode. \
Recovery mode opens the document to be fully accessible to owners, and inaccessible to others. \
It also disables formulas. [{{error}}]", {error: err.message})
: isDenied
? t('Sorry, access to this document has been denied. [{{error}}]', {error: err.message})
: t("Document owners can attempt to recover the document. [{{error}}]", {error: err.message})

@ -12,6 +12,9 @@ import {TableData} from 'app/common/TableData';
import {BaseFormatter} from 'app/common/ValueFormatter';
import {Computed, Disposable, Observable} from 'grainjs';
import debounce = require('lodash/debounce');
import { makeT } from 'app/client/lib/localization';
const t = makeT('SearchModel');
/**
* SearchModel used to maintain the state of the search UI.
@ -201,7 +204,7 @@ class FinderImpl implements IFinder {
// sort in order that is the same as on the raw data list page,
.sort((a, b) => nativeCompare(a.tableNameDef.peek(), b.tableNameDef.peek()))
// get rawViewSection,
.map(t => t.rawViewSection.peek())
.map(table => table.rawViewSection.peek())
// and test if it isn't an empty record.
.filter(s => Boolean(s.id.peek()));
// Pretend that those are pages.
@ -218,7 +221,7 @@ class FinderImpl implements IFinder {
// Else read all visible pages.
const pages = this._gristDoc.docModel.visibleDocPages.peek();
this._pageStepper.array = pages.map(p => new PageRecWrapper(p, this._openDocPageCB));
this._pageStepper.index = pages.findIndex(t => t.viewRef.peek() === this._gristDoc.activeViewId.get());
this._pageStepper.index = pages.findIndex(page => page.viewRef.peek() === this._gristDoc.activeViewId.get());
if (this._pageStepper.index < 0) { return false; }
}
@ -468,7 +471,7 @@ export class SearchModelImpl extends Disposable implements SearchModel {
this.autoDispose(this.multiPage.addListener(v => { if (v) { this.noMatch.set(false); } }));
this.allLabel = Computed.create(this, use => use(this._gristDoc.activeViewId) === 'data' ?
'Search all tables' : 'Search all pages');
t('Search all tables') : t('Search all pages'));
// Schedule a search restart when user changes pages (otherwise search would resume from the
// previous page that is not shown anymore). Also revert noMatch flag when in single page mode.

@ -131,9 +131,8 @@ export class AccountPage extends Disposable {
),
css.subHeader(t("Two-factor authentication")),
css.description(
t("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.")
t("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.")
),
dom.create(MFAConfig, user),
),

@ -78,8 +78,8 @@ export class ApiKey extends Disposable {
dom.maybe((use) => !(use(this._apiKey) || this._anonymous), () => [
basicButton(t("Create"), dom.on('click', () => this._onCreate()), testId('create'),
dom.boolAttr('disabled', this._loading)),
description(t("By generating an API key, you will be able to " +
"make API calls for your own account."), testId('description')),
description(t("By generating an API key, you will be able to \
make API calls for your own account."), testId('description')),
]),
);
}
@ -117,8 +117,8 @@ export class ApiKey extends Disposable {
() => this._onDelete(),
{
explanation: t(
"You're about to delete an API key. This will cause all future requests " +
"using this API key to be rejected. Do you still want to delete?"
"You're about to delete an API key. This will cause all future requests \
using this API key to be rejected. Do you still want to delete?"
),
}
);

@ -21,8 +21,8 @@ export async function startDocTour(docData: DocData, docComm: DocComm, onFinishC
const invalidDocTour: IOnBoardingMsg[] = [{
title: t("No valid document tour"),
body: t("Cannot construct a document tour from the data in this document. " +
"Ensure there is a table named GristDocTour with columns Title, Body, Placement, and Location."),
body: t("Cannot construct a document tour from the data in this document. \
Ensure there is a table named GristDocTour with columns Title, Body, Placement, and Location."),
selector: 'document',
showHasModal: true,
}];

@ -36,8 +36,8 @@ export const buildExamples = (): IExampleInfo[] => [{
tutorialUrl: 'https://support.getgrist.com/investment-research/',
welcomeCard: {
title: t("Welcome to the Investment Research template"),
text: t("Check out our related tutorial to learn how to create " +
"summary tables and charts, and to link charts dynamically."),
text: t("Check out our related tutorial to learn how to create \
summary tables and charts, and to link charts dynamically."),
tutorialName: t("Tutorial: Analyze & Visualize"),
},
}, {

@ -50,8 +50,7 @@ export const GristTooltips: Record<Tooltip, TooltipContentFunc> = {
t('Formulas that trigger in certain cases, and store the calculated value as data.')
),
dom('div',
t('Useful for storing the timestamp or author of a new record, data cleaning, and '
+ 'more.')
t('Useful for storing the timestamp or author of a new record, data cleaning, and more.')
),
dom('div',
cssLink({href: commonUrls.helpTriggerFormulas, target: '_blank'}, t('Learn more.')),
@ -76,8 +75,8 @@ export const GristTooltips: Record<Tooltip, TooltipContentFunc> = {
),
openAccessRules: (...args: DomElementArg[]) => cssTooltipContent(
dom('div',
t('Access rules give you the power to create nuanced rules to determine who can '
+ 'see or edit which parts of your document.')
t('Access rules give you the power to create nuanced rules to determine who can \
see or edit which parts of your document.')
),
dom('div',
cssLink({href: commonUrls.helpAccessRules, target: '_blank'}, t('Learn more.')),
@ -126,8 +125,8 @@ export const GristBehavioralPrompts: Record<BehavioralPrompt, BehavioralPromptCo
title: () => t('Reference Columns'),
content: (...args: DomElementArg[]) => cssTooltipContent(
dom('div', t('Select the table to link to.')),
dom('div', t('Cells in a reference column always identify an {{entire}} ' +
'record in that table, but you may select which column from that record to show.', {
dom('div', t('Cells in a reference column always identify an {{entire}} \
record in that table, but you may select which column from that record to show.', {
entire: cssItalicizedText(t('entire'))
})),
dom('div',
@ -140,8 +139,8 @@ export const GristBehavioralPrompts: Record<BehavioralPrompt, BehavioralPromptCo
rawDataPage: {
title: () => t('Raw Data page'),
content: (...args: DomElementArg[]) => cssTooltipContent(
dom('div', t('The Raw Data page lists all data tables in your document, '
+ 'including summary tables and tables not included in page layouts.')),
dom('div', t('The Raw Data page lists all data tables in your document, \
including summary tables and tables not included in page layouts.')),
dom('div', cssLink({href: commonUrls.helpRawData, target: '_blank'}, t('Learn more.'))),
...args,
),
@ -150,8 +149,8 @@ export const GristBehavioralPrompts: Record<BehavioralPrompt, BehavioralPromptCo
accessRules: {
title: () => t('Access Rules'),
content: (...args: DomElementArg[]) => cssTooltipContent(
dom('div', t('Access rules give you the power to create nuanced rules '
+ 'to determine who can see or edit which parts of your document.')),
dom('div', t('Access rules give you the power to create nuanced rules \
to determine who can see or edit which parts of your document.')),
dom('div', cssLink({href: commonUrls.helpAccessRules, target: '_blank'}, t('Learn more.'))),
...args,
),
@ -209,8 +208,7 @@ export const GristBehavioralPrompts: Record<BehavioralPrompt, BehavioralPromptCo
addNew: {
title: () => t('Add New'),
content: (...args: DomElementArg[]) => cssTooltipContent(
dom('div', t('Click the Add New button to create new documents or workspaces, '
+ 'or import data.')),
dom('div', t('Click the Add New button to create new documents or workspaces, or import data.')),
...args,
),
deploymentTypes: ['saas'],
@ -219,8 +217,7 @@ export const GristBehavioralPrompts: Record<BehavioralPrompt, BehavioralPromptCo
title: () => t('Anchor Links'),
content: (...args: DomElementArg[]) => cssTooltipContent(
dom('div',
t('To make an anchor link that takes the user to a specific cell, click on'
+ ' a row and press {{shortcut}}.',
t('To make an anchor link that takes the user to a specific cell, click on a row and press {{shortcut}}.',
{
shortcut: ShortcutKey(ShortcutKeyContent(commands.allCommands.copyLink.humanKeys[0])),
}
@ -235,8 +232,7 @@ export const GristBehavioralPrompts: Record<BehavioralPrompt, BehavioralPromptCo
content: (...args: DomElementArg[]) => cssTooltipContent(
dom('div',
t(
'You can choose one of our pre-made widgets or embed your own ' +
'by providing its full URL.'
'You can choose one of our pre-made widgets or embed your own by providing its full URL.'
),
),
dom('div', cssLink({href: commonUrls.helpCustomWidgets, target: '_blank'}, t('Learn more.'))),

@ -41,8 +41,8 @@ export async function replaceTrunkWithFork(user: FullUser|null, doc: Document, a
if (cmp.summary === 'left' || cmp.summary === 'both') {
titleText = t("Original Has Modifications");
buttonText = t("Overwrite");
warningText = `${warningText} ${t("Be careful, the original has changes " +
"not in this document. Those changes will be overwritten.")}`;
warningText = `${warningText} ${t("Be careful, the original has changes \
not in this document. Those changes will be overwritten.")}`;
} else if (cmp.summary === 'unrelated') {
titleText = t("Original Looks Unrelated");
buttonText = t("Overwrite");

@ -102,7 +102,7 @@ function shareButton(buttonText: string|null, menuCreateFunc: MenuCreateFunc,
return cssHoverCircle({ style: `margin: 5px;` },
cssTopBarBtn('Share', dom.cls('tour-share-icon')),
menu(menuCreateFunc, {placement: 'bottom-end'}),
hoverTooltip('Share', {key: 'topBarBtnTooltip'}),
hoverTooltip(t('Share'), {key: 'topBarBtnTooltip'}),
testId('tb-share'),
);
} else if (options.buttonAction) {
@ -115,7 +115,7 @@ function shareButton(buttonText: string|null, menuCreateFunc: MenuCreateFunc,
cssShareCircle(
cssShareIcon('Share'),
menu(menuCreateFunc, {placement: 'bottom-end'}),
hoverTooltip('Share', {key: 'topBarBtnTooltip'}),
hoverTooltip(t('Share'), {key: 'topBarBtnTooltip'}),
testId('tb-share'),
),
);
@ -128,7 +128,7 @@ function shareButton(buttonText: string|null, menuCreateFunc: MenuCreateFunc,
cssShareIcon('Share')
),
menu(menuCreateFunc, {placement: 'bottom-end'}),
hoverTooltip('Share', {key: 'topBarBtnTooltip'}),
hoverTooltip(t('Share'), {key: 'topBarBtnTooltip'}),
testId('tb-share'),
);
}

@ -5,6 +5,7 @@
*
* It can be instantiated by calling showUserManagerModal with the UserAPI and IUserManagerOptions.
*/
import { makeT } from 'app/client/lib/localization';
import {commonUrls} from 'app/common/gristUrls';
import {capitalizeFirstWord, isLongerThan} from 'app/common/gutil';
import {FullUser} from 'app/common/LoginSessionAPI';
@ -42,6 +43,8 @@ import {menu, menuItem, menuText} from 'app/client/ui2018/menus';
import {confirmModal, cssAnimatedModal, cssModalBody, cssModalButtons, cssModalTitle,
IModalControl, modal} from 'app/client/ui2018/modals';
const t = makeT('UserManager');
export interface IUserManagerOptions {
permissionData: Promise<PermissionData>;
activeUser: FullUser|null;
@ -101,15 +104,15 @@ export function showUserManagerModal(userApi: UserAPI, options: IUserManagerOpti
}
};
if (model.isSelfRemoved.get()) {
const name = resourceName(model.resourceType);
const resourceType = resourceName(model.resourceType);
confirmModal(
`You are about to remove your own access to this ${name}`,
'Remove my access', tryToSaveChanges,
t(`You are about to remove your own access to this {{resourceType}}`, { resourceType }),
t('Remove my access'), tryToSaveChanges,
{
explanation: (
'Once you have removed your own access, ' +
'you will not be able to get it back without assistance ' +
`from someone else with sufficient access to the ${name}.`
t(`Once you have removed your own access, \
you will not be able to get it back without assistance \
from someone else with sufficient access to the {{resourceType}}.`, { resourceType })
),
}
);
@ -162,22 +165,22 @@ function buildUserManagerModal(
cssModalButtons(
{ style: 'margin: 32px 64px; display: flex;' },
(model.isPublicMember ? null :
bigPrimaryButton('Confirm',
bigPrimaryButton(t('Confirm'),
dom.boolAttr('disabled', (use) => !use(model.isAnythingChanged)),
dom.on('click', () => onConfirm(ctl)),
testId('um-confirm')
)
),
bigBasicButton(
model.isPublicMember ? 'Close' : 'Cancel',
model.isPublicMember ? t('Close') : t('Cancel'),
dom.on('click', () => ctl.close()),
testId('um-cancel')
),
(model.resourceType === 'document' && model.gristDoc && !model.isPersonal
? withInfoTooltip(
cssLink({href: urlState().makeUrl({docPage: 'acl'})},
dom.text(use => use(model.isAnythingChanged) ? 'Save & ' : ''),
'Open Access Rules',
dom.text(use => use(model.isAnythingChanged) ? t('Save & ') : ''),
t('Open Access Rules'),
dom.on('click', (ev) => {
ev.preventDefault();
return onConfirm(ctl).then(() => urlState().pushUrl({docPage: 'acl'}));
@ -268,7 +271,7 @@ export class UserManager extends Disposable {
return dom('div',
cssOptionRowMultiple(
icon('AddUser'),
cssLabel('Invite multiple'),
cssLabel(t('Invite multiple')),
dom.on('click', (_ev) => buildMultiUserManagerModal(
this,
this._model,
@ -286,30 +289,31 @@ export class UserManager extends Disposable {
),
publicMember ? dom('span', { style: `float: right;` },
cssSmallPublicMemberIcon('PublicFilled'),
dom('span', 'Public access: '),
dom('span', t('Public access: ')),
cssOptionBtn(
menu(() => {
tooltipControl?.close();
return [
menuItem(() => publicMember.access.set(roles.VIEWER), 'On', testId(`um-public-option`)),
menuItem(() => publicMember.access.set(null), 'Off',
menuItem(() => publicMember.access.set(roles.VIEWER), t('On'), testId(`um-public-option`)),
menuItem(() => publicMember.access.set(null), t('Off'),
// Disable null access if anonymous access is inherited.
dom.cls('disabled', (use) => use(publicMember.inheritedAccess) !== null),
testId(`um-public-option`)
),
// If the 'Off' setting is disabled, show an explanation.
dom.maybe((use) => use(publicMember.inheritedAccess) !== null, () => menuText(
`Public access inherited from ${getResourceParent(this._model.resourceType)}. ` +
`To remove, set 'Inherit access' option to 'None'.`))
t(`Public access inherited from {{parent}}. To remove, set 'Inherit access' option to 'None'.`,
{ parent: getResourceParent(this._model.resourceType) }
)))
];
}),
dom.text((use) => use(publicMember.effectiveAccess) ? 'On' : 'Off'),
dom.text((use) => use(publicMember.effectiveAccess) ? t('On') : t('Off')),
cssCollapseIcon('Collapse'),
testId('um-public-access')
),
hoverTooltip((ctl) => {
tooltipControl = ctl;
return 'Allow anyone with the link to open.';
return t('Allow anyone with the link to open.');
}),
) : null,
),
@ -373,19 +377,23 @@ export class UserManager extends Disposable {
const annotation = annotations.users.get(member.email);
if (!annotation) { return null; }
if (annotation.isSupport) {
return cssMemberType('Grist support');
return cssMemberType(t('Grist support'));
}
if (annotation.isMember && annotations.hasTeam) {
return cssMemberType('Team member');
return cssMemberType(t('Team member'));
}
const collaborator = annotations.hasTeam ? 'guest' : 'free collaborator';
const collaborator = annotations.hasTeam ? t('guest') : t('free collaborator');
const limit = annotation.collaboratorLimit;
if (!limit || !limit.top) { return null; }
const elements: HTMLSpanElement[] = [];
if (limit.at <= limit.top) {
elements.push(cssMemberType(`${limit.at} of ${limit.top} ${collaborator}s`));
elements.push(cssMemberType(
t(`{{limitAt}} of {{limitTop}} {{collaborator}}s`, { limitAt: limit.at, limitTop: limit.top, collaborator }))
);
} else {
elements.push(cssMemberTypeProblem(`${capitalizeFirstWord(collaborator)} limit exceeded`));
elements.push(cssMemberTypeProblem(
t(`{{collaborator}} limit exceeded`, { collaborator: capitalizeFirstWord(collaborator) }))
);
}
if (annotations.hasTeam) {
// Add a link for adding a member. For a doc, streamline this so user can make
@ -401,10 +409,10 @@ export class UserManager extends Disposable {
{ email: member.email }).catch(reportError);
}
}),
`Add ${member.name || 'member'} to your team`));
t(`Add {{member}} to your team`, { member: member.name || t('member') })));
} else if (limit.at >= limit.top) {
elements.push(cssLink({href: commonUrls.plans, target: '_blank'},
'Create a team to share with more people'));
t('Create a team to share with more people')));
}
return elements;
});
@ -418,13 +426,13 @@ export class UserManager extends Disposable {
let memberType: string;
if (annotation.isSupport) {
memberType = 'Grist support';
memberType = t('Grist support');
} else if (annotation.isMember && annotations.hasTeam) {
memberType = 'Team member';
memberType = t('Team member');
} else if (annotations.hasTeam) {
memberType = 'Outside collaborator';
memberType = t('Outside collaborator');
} else {
memberType = 'Collaborator';
memberType = t('Collaborator');
}
return cssMemberType(memberType, testId('um-member-annotation'));
@ -439,8 +447,8 @@ export class UserManager extends Disposable {
cssMemberListItem(
cssPublicMemberIcon('PublicFilled'),
cssMemberText(
cssMemberPrimary('Public Access'),
cssMemberSecondary('Anyone with link ', makeCopyBtn(this._options.linkToCopy)),
cssMemberPrimary(t('Public Access')),
cssMemberSecondary(t('Anyone with link '), makeCopyBtn(this._options.linkToCopy)),
),
this._memberRoleSelector(publicMember.effectiveAccess, publicMember.inheritedAccess, false,
this._model.publicUserSelectOptions
@ -472,12 +480,12 @@ export class UserManager extends Disposable {
cssMemberPrimary(name, testId('um-member-name')),
activeUser?.email ? cssMemberSecondary(activeUser.email) : null,
cssMemberPublicAccess(
dom('span', 'Public access', testId('um-member-annotation')),
dom('span', t('Public access'), testId('um-member-annotation')),
cssPublicAccessIcon('PublicFilled'),
),
),
cssRoleBtn(
accessLabel ?? 'Guest',
accessLabel ?? t('Guest'),
cssCollapseIcon('Collapse'),
dom.cls('disabled'),
testId('um-member-role'),
@ -522,23 +530,24 @@ export class UserManager extends Disposable {
)
),
// If the user's access is inherited, give an explanation on how to change it.
isActiveUser ? menuText(`User may not modify their own access.`) : null,
isActiveUser ? menuText(t(`User may not modify their own access.`)) : null,
// If the user's access is inherited, give an explanation on how to change it.
dom.maybe((use) => use(inherited) && !isActiveUser, () => menuText(
`User inherits permissions from ${getResourceParent(this._model.resourceType)}. To remove, ` +
`set 'Inherit access' option to 'None'.`)),
t(`User inherits permissions from {{parent}}. To remove, \
set 'Inherit access' option to 'None'.`, { parent: getResourceParent(this._model.resourceType) }))),
// If the user is a guest, give a description of the guest permission.
dom.maybe((use) => !this._model.isOrg && use(role) === roles.GUEST, () => menuText(
`User has view access to ${this._model.resourceType} resulting from manually-set access ` +
`to resources inside. If removed here, this user will lose access to resources inside.`)),
this._model.isOrg ? menuText(`No default access allows access to be ` +
`granted to individual documents or workspaces, rather than the full team site.`) : null
t(`User has view access to {{resource}} resulting from manually-set access \
to resources inside. If removed here, this user will lose access to resources inside.`,
{ resource: this._model.resourceType }))),
this._model.isOrg ? menuText(t(`No default access allows access to be \
granted to individual documents or workspaces, rather than the full team site.`)) : null
]),
dom.text((use) => {
// Get the label of the active role. Note that the 'Guest' role is assigned when the role
// is not found because it is not included as a selection.
const activeRole = allRoles.find((_role: IOrgMemberSelectOption) => use(role) === _role.value);
return activeRole ? activeRole.label : "Guest";
return activeRole ? activeRole.label : t("Guest");
}),
cssCollapseIcon('Collapse'),
this._model.isPersonal ? dom.cls('disabled') : null,
@ -634,7 +643,7 @@ function getFullUser(member: IEditableMember): FullUser {
// Create a "Copy Link" button.
function makeCopyBtn(linkToCopy: string|undefined, ...domArgs: DomElementArg[]) {
return linkToCopy && cssCopyBtn(cssCopyIcon('Copy'), 'Copy Link',
return linkToCopy && cssCopyBtn(cssCopyIcon('Copy'), t('Copy Link'),
dom.on('click', (ev, elem) => copyLink(elem, linkToCopy)),
testId('um-copy-link'),
...domArgs,
@ -646,7 +655,7 @@ function makeCopyBtn(linkToCopy: string|undefined, ...domArgs: DomElementArg[])
async function copyLink(elem: HTMLElement, link: string) {
await copyToClipboard(link);
setTestState({clipboard: link});
showTransientTooltip(elem, 'Link copied to clipboard', {key: 'copy-doc-link'});
showTransientTooltip(elem, t('Link copied to clipboard'), { key: 'copy-doc-link' });
}
async function manageTeam(appModel: AppModel,
@ -808,9 +817,9 @@ const cssMemberPublicAccess = styled(cssMemberSecondary, `
function renderTitle(resourceType: ResourceType, resource?: Resource, personal?: boolean) {
switch (resourceType) {
case 'organization': {
if (personal) { return 'Your role for this team site'; }
if (personal) { return t('Your role for this team site'); }
return [
'Manage members of team site',
t('Manage members of team site'),
!resource ? null : cssOrgName(
`${(resource as Organization).name} (`,
cssOrgDomain(`${(resource as Organization).domain}.getgrist.com`),
@ -819,12 +828,14 @@ function renderTitle(resourceType: ResourceType, resource?: Resource, personal?:
];
}
default: {
return personal ? `Your role for this ${resourceType}` : `Invite people to ${resourceType}`;
return personal ?
t(`Your role for this {{resourceType}}`, { resourceType }) :
t(`Invite people to {{resourceType}}`, { resourceType });
}
}
}
// Rename organization to team site.
function resourceName(resourceType: ResourceType): string {
return resourceType === 'organization' ? 'team site' : resourceType;
return resourceType === 'organization' ? t('team site') : resourceType;
}

@ -10,7 +10,7 @@ import { dom, styled } from "grainjs";
const t = makeT('WelcomeTour');
export const welcomeTour: IOnBoardingMsg[] = [
export const WelcomeTour: IOnBoardingMsg[] = [
{
title: t('Editing Data'),
body: () => [
@ -97,7 +97,7 @@ export const welcomeTour: IOnBoardingMsg[] = [
export function startWelcomeTour(onFinishCB: () => void) {
commands.allCommands.fieldTabOpen.run();
startOnBoarding(welcomeTour, onFinishCB);
startOnBoarding(WelcomeTour, onFinishCB);
}
const TopBarButtonIcon = styled(icon, `

@ -35,8 +35,8 @@ export function createForbiddenPage(appModel: AppModel, message?: string) {
return pagePanelsError(appModel, t("Access denied{{suffix}}", {suffix: ''}), [
dom.domComputed(appModel.currentValidUser, user => user ? [
cssErrorText(message || t("You do not have access to this organization's documents.")),
cssErrorText(t("You are signed in as {{email}}. You can sign in with a different " +
"account, or ask an administrator for access.", {email: dom('b', user.email)})),
cssErrorText(t("You are signed in as {{email}}. You can sign in with a different \
account, or ask an administrator for access.", {email: dom('b', user.email)})),
] : [
// This page is not normally shown because a logged out user with no access will get
// redirected to log in. But it may be seen if a user logs out and returns to a cached

@ -10,6 +10,9 @@ import { icon } from "app/client/ui2018/icons";
import { cssMenuItem, defaultMenuOptions, IOpenController, IPopupOptions, setPopupToFunc } from "popweasel";
import { mergeWith } from "lodash";
import { getOptionFull, SimpleList } from "../lib/simpleList";
import { makeT } from 'app/client/lib/localization';
const t = makeT('searchDropdown');
const testId = makeTestId('test-sd-');
@ -92,7 +95,7 @@ class DropdownWithSearch<T> extends Disposable {
cssMenuHeader(
cssSearchIcon('Search'),
this._inputElem = cssSearch(
{placeholder: this._options.placeholder || 'Search'},
{placeholder: this._options.placeholder || t('Search')},
dom.on('input', () => { this._update(); }),
dom.on('blur', () => setTimeout(() => this._inputElem.focus(), 0)),
),

@ -177,7 +177,7 @@ export function searchBar(model: SearchModel, testId: TestId = noTestId) {
cssTopBarBtn('Search',
testId('icon'),
dom.on('click', focusAndSelect),
hoverTooltip('Search', {key: 'topBarBtnTooltip'}),
hoverTooltip(t('Search'), {key: 'topBarBtnTooltip'}),
)
),
expandedSearch(

@ -7,23 +7,23 @@ export class Limit extends BaseEntity {
@PrimaryGeneratedColumn()
public id: number;
@Column()
@Column({type: Number})
public limit: number;
@Column()
@Column({type: Number})
public usage: number;
@Column()
@Column({type: String})
public type: string;
@Column({name: 'billing_account_id'})
@Column({name: 'billing_account_id', type: Number})
public billingAccountId: number;
@ManyToOne(type => BillingAccount)
@JoinColumn({name: 'billing_account_id'})
public billingAccount: BillingAccount;
@Column({name: 'created_at', default: () => "CURRENT_TIMESTAMP"})
@Column({name: 'created_at', type: nativeValues.dateTimeType, default: () => "CURRENT_TIMESTAMP"})
public createdAt: Date;
/**

@ -80,7 +80,7 @@ import {guessColInfo} from 'app/common/ValueGuesser';
import {parseUserAction} from 'app/common/ValueParser';
import {TEMPLATES_ORG_DOMAIN} from 'app/gen-server/ApiServer';
import {Document} from 'app/gen-server/entity/Document';
import {ParseOptions} from 'app/plugin/FileParserAPI';
import {ParseFileResult, ParseOptions} from 'app/plugin/FileParserAPI';
import {AccessTokenOptions, AccessTokenResult, GristDocAPI} from 'app/plugin/GristAPI';
import {compileAclFormula} from 'app/server/lib/ACLFormula';
import {AssistanceSchemaPromptV1Context} from 'app/server/lib/Assistance';
@ -113,7 +113,7 @@ import tmp from 'tmp';
import {ActionHistory} from './ActionHistory';
import {ActionHistoryImpl} from './ActionHistoryImpl';
import {ActiveDocImport} from './ActiveDocImport';
import {ActiveDocImport, FileImportOptions} from './ActiveDocImport';
import {DocClients} from './DocClients';
import {DocPluginManager} from './DocPluginManager';
import {
@ -773,6 +773,17 @@ export class ActiveDoc extends EventEmitter {
await this._activeDocImport.oneStepImport(docSession, uploadInfo);
}
/**
* Import data resulting from parsing a file into a new table.
* In normal circumstances this is only used internally.
* It's exposed publicly for use by grist-static which doesn't use the plugin system.
*/
public async importParsedFileAsNewTable(
docSession: OptDocSession, optionsAndData: ParseFileResult, importOptions: FileImportOptions
): Promise<ImportResult> {
return this._activeDocImport.importParsedFileAsNewTable(docSession, optionsAndData, importOptions);
}
/**
* This function saves attachments from a given upload and creates an entry for them in the database.
* It returns the list of rowIds for the rows created in the _grist_Attachments table.

@ -44,7 +44,7 @@ interface ReferenceDescription {
refTableId: string;
}
interface FileImportOptions {
export interface FileImportOptions {
// Suggested name of the import file. It is sometimes used as a suggested table name, e.g. for csv imports.
originalFilename: string;
// Containing parseOptions as serialized JSON to pass to the import plugin.
@ -227,71 +227,14 @@ export class ActiveDocImport {
}
/**
* Imports all files as new tables, using the given transform rules and import options.
* The isHidden flag indicates whether to create temporary hidden tables, or final ones.
*/
private async _importFiles(docSession: OptDocSession, upload: UploadInfo, transforms: TransformRuleMap[],
{parseOptions = {}, mergeOptionMaps = []}: ImportOptions,
isHidden: boolean): Promise<ImportResult> {
// Check that upload size is within the configured limits.
const limit = (Number(process.env.GRIST_MAX_UPLOAD_IMPORT_MB) * 1024 * 1024) || Infinity;
const totalSize = upload.files.reduce((acc, f) => acc + f.size, 0);
if (totalSize > limit) {
throw new ApiError(`Imported files must not exceed ${gutil.byteString(limit)}`, 413);
}
// The upload must be within the plugin-accessible directory. Once moved, subsequent calls to
// moveUpload() will return without having to do anything.
if (!this._activeDoc.docPluginManager) { throw new Error('no plugin manager available'); }
await moveUpload(upload, this._activeDoc.docPluginManager.tmpDir());
const importResult: ImportResult = {options: parseOptions, tables: []};
for (const [index, file] of upload.files.entries()) {
// If we have a better guess for the file's extension, replace it in origName, to ensure
// that DocPluginManager has access to it to guess the best parser type.
let origName: string = file.origName;
if (file.ext) {
origName = path.basename(origName, path.extname(origName)) + file.ext;
}
const res = await this._importFileAsNewTable(docSession, file.absPath, {
parseOptions,
mergeOptionsMap: mergeOptionMaps[index] || {},
isHidden,
originalFilename: origName,
uploadFileIndex: index,
transformRuleMap: transforms[index] || {}
});
if (index === 0) {
// Returned parse options from the first file should be used for all files in one upload.
importResult.options = parseOptions = res.options;
}
importResult.tables.push(...res.tables);
}
return importResult;
}
/**
* Imports the data stored at tmpPath.
*
* Currently it starts a python parser as a child process
* outside the sandbox, and supports xlsx, csv, and perhaps some other formats. It may
* result in the import of multiple tables, in case of e.g. Excel formats.
* @param {OptDocSession} docSession: Session instance to use for importing.
* @param {String} tmpPath: The path from of the original file.
* @param {FileImportOptions} importOptions: File import options.
* @returns {Promise<ImportResult>} with `options` property containing parseOptions as serialized JSON as adjusted
* or guessed by the plugin, and `tables`, which is which is a list of objects with information about
* tables, such as `hiddenTableId`, `uploadFileIndex`, `origTableName`, `transformSectionRef`, `destTableId`.
* Import data resulting from parsing a file into a new table.
* In normal circumstances this is only used internally.
* It's exposed publicly for use by grist-static which doesn't use the plugin system.
*/
private async _importFileAsNewTable(docSession: OptDocSession, tmpPath: string,
importOptions: FileImportOptions): Promise<ImportResult> {
const {originalFilename, parseOptions, mergeOptionsMap, isHidden, uploadFileIndex,
transformRuleMap} = importOptions;
log.info("ActiveDoc._importFileAsNewTable(%s, %s)", tmpPath, originalFilename);
if (!this._activeDoc.docPluginManager) { throw new Error('no plugin manager available'); }
const optionsAndData: ParseFileResult =
await this._activeDoc.docPluginManager.parseFile(tmpPath, originalFilename, parseOptions);
public async importParsedFileAsNewTable(
docSession: OptDocSession, optionsAndData: ParseFileResult, importOptions: FileImportOptions
): Promise<ImportResult> {
const {originalFilename, mergeOptionsMap, isHidden, uploadFileIndex, transformRuleMap} = importOptions;
const options = optionsAndData.parseOptions;
const parsedTables = optionsAndData.tables;
@ -374,6 +317,76 @@ export class ActiveDocImport {
return ({options, tables});
}
/**
* Imports all files as new tables, using the given transform rules and import options.
* The isHidden flag indicates whether to create temporary hidden tables, or final ones.
*/
private async _importFiles(docSession: OptDocSession, upload: UploadInfo, transforms: TransformRuleMap[],
{parseOptions = {}, mergeOptionMaps = []}: ImportOptions,
isHidden: boolean): Promise<ImportResult> {
// Check that upload size is within the configured limits.
const limit = (Number(process.env.GRIST_MAX_UPLOAD_IMPORT_MB) * 1024 * 1024) || Infinity;
const totalSize = upload.files.reduce((acc, f) => acc + f.size, 0);
if (totalSize > limit) {
throw new ApiError(`Imported files must not exceed ${gutil.byteString(limit)}`, 413);
}
// The upload must be within the plugin-accessible directory. Once moved, subsequent calls to
// moveUpload() will return without having to do anything.
if (!this._activeDoc.docPluginManager) { throw new Error('no plugin manager available'); }
await moveUpload(upload, this._activeDoc.docPluginManager.tmpDir());
const importResult: ImportResult = {options: parseOptions, tables: []};
for (const [index, file] of upload.files.entries()) {
// If we have a better guess for the file's extension, replace it in origName, to ensure
// that DocPluginManager has access to it to guess the best parser type.
let origName: string = file.origName;
if (file.ext) {
origName = path.basename(origName, path.extname(origName)) + file.ext;
}
const res = await this._importFileAsNewTable(docSession, file.absPath, {
parseOptions,
mergeOptionsMap: mergeOptionMaps[index] || {},
isHidden,
originalFilename: origName,
uploadFileIndex: index,
transformRuleMap: transforms[index] || {}
});
if (index === 0) {
// Returned parse options from the first file should be used for all files in one upload.
importResult.options = parseOptions = res.options;
}
importResult.tables.push(...res.tables);
}
return importResult;
}
/**
* Imports the data stored at tmpPath.
*
* Currently it starts a python parser as a child process
* outside the sandbox, and supports xlsx, csv, and perhaps some other formats. It may
* result in the import of multiple tables, in case of e.g. Excel formats.
* @param {OptDocSession} docSession: Session instance to use for importing.
* @param {String} tmpPath: The path from of the original file.
* @param {FileImportOptions} importOptions: File import options.
* @returns {Promise<ImportResult>} with `options` property containing parseOptions as serialized JSON as adjusted
* or guessed by the plugin, and `tables`, which is which is a list of objects with information about
* tables, such as `hiddenTableId`, `uploadFileIndex`, `origTableName`, `transformSectionRef`, `destTableId`.
*/
private async _importFileAsNewTable(docSession: OptDocSession, tmpPath: string,
importOptions: FileImportOptions): Promise<ImportResult> {
const {originalFilename, parseOptions} = importOptions;
log.info("ActiveDoc._importFileAsNewTable(%s, %s)", tmpPath, originalFilename);
if (!this._activeDoc.docPluginManager) {
throw new Error('no plugin manager available');
}
const optionsAndData: ParseFileResult =
await this._activeDoc.docPluginManager.parseFile(tmpPath, originalFilename, parseOptions);
return this.importParsedFileAsNewTable(docSession, optionsAndData, importOptions);
}
/**
* Imports records from `hiddenTableId` into `destTableId`, transforming the column
* values from `hiddenTableId` according to the `transformRule`. Finalizes import when done.

@ -378,7 +378,7 @@ export class DocStorage implements ISQLiteDB, OnDemandStorage {
const colListSql = newCols.map(c => `${quoteIdent(c.colId)}=?`).join(', ');
const types = newCols.map(c => c.type);
const sqlParams = DocStorage._encodeColumnsToRows(types, newCols.map(c => [PENDING_VALUE]));
await db.run(`UPDATE ${quoteIdent(tableId)} SET ${colListSql}`, sqlParams[0]);
await db.run(`UPDATE ${quoteIdent(tableId)} SET ${colListSql}`, ...sqlParams[0]);
}
},
@ -1093,7 +1093,7 @@ export class DocStorage implements ISQLiteDB, OnDemandStorage {
public _process_RemoveRecord(tableId: string, rowId: string): Promise<RunResult> {
const sql = "DELETE FROM " + quoteIdent(tableId) + " WHERE id=?";
debuglog("RemoveRecord SQL: " + sql, [rowId]);
return this.run(sql, [rowId]);
return this.run(sql, rowId);
}
@ -1130,7 +1130,7 @@ export class DocStorage implements ISQLiteDB, OnDemandStorage {
const stmt = await this.prepare(preSql + chunkParams + postSql);
for (const index of _.range(0, numChunks * chunkSize, chunkSize)) {
debuglog("DocStorage.BulkRemoveRecord: chunk delete " + index + "-" + (index + chunkSize - 1));
await stmt.run(rowIds.slice(index, index + chunkSize));
await stmt.run(...rowIds.slice(index, index + chunkSize));
}
await stmt.finalize();
}
@ -1139,7 +1139,7 @@ export class DocStorage implements ISQLiteDB, OnDemandStorage {
debuglog("DocStorage.BulkRemoveRecord: leftover delete " + (numChunks * chunkSize) + "-" + (rowIds.length - 1));
const leftoverParams = _.range(numLeftovers).map(q).join(',');
await this.run(preSql + leftoverParams + postSql,
rowIds.slice(numChunks * chunkSize, rowIds.length));
...rowIds.slice(numChunks * chunkSize, rowIds.length));
}
}

@ -1132,9 +1132,6 @@ export class FlexServer implements GristServer {
await this.loadConfig();
this.addComm();
// Temporary duplication of external storage configuration.
// This may break https://github.com/gristlabs/grist-core/pull/546,
// but will revive other uses of external storage. TODO: reconcile.
await this.create.configure?.();
if (!isSingleUserMode()) {
@ -1147,7 +1144,7 @@ export class FlexServer implements GristServer {
this._disableExternalStorage = true;
externalStorage.flag('active').set(false);
}
await this.create.configure?.();
await this.create.checkBackend?.();
const workers = this._docWorkerMap;
const docWorkerId = await this._addSelfAsWorker(workers);

@ -32,6 +32,8 @@ export interface ICreate {
sessionSecret(): string;
// Check configuration of the app early enough to show on startup.
configure?(): Promise<void>;
// Optionally perform sanity checks on the configured storage, throwing a fatal error if it is not functional
checkBackend?(): Promise<void>;
// Return a string containing 1 or more HTML tags to insert into the head element of every
// static page.
getExtraHeadHtml?(): string;
@ -119,6 +121,13 @@ export function makeSimpleCreator(opts: {
return secret;
},
async configure() {
for (const s of storage || []) {
if (s.check()) {
break;
}
}
},
async checkBackend() {
for (const s of storage || []) {
if (s.check()) {
await s.checkBackend?.();

@ -71,7 +71,11 @@
"Sign Out": "Abmelden",
"Sign in": "Anmelden",
"Switch Accounts": "Konten wechseln",
"Toggle Mobile Mode": "Mobilmodus umschalten"
"Toggle Mobile Mode": "Mobilmodus umschalten",
"Activation": "Aktivierung",
"Billing Account": "Abrechnungskonto",
"Support Grist": "Grist Support",
"Upgrade Plan": "Upgrade-Plan"
},
"ActionLog": {
"Action Log failed to load": "Aktionsprotokoll konnte nicht geladen werden",
@ -511,7 +515,8 @@
"Notifications": "Benachrichtigungen",
"Renew": "Erneuern",
"Report a problem": "Ein Problem melden",
"Upgrade Plan": "Upgrade-Plan"
"Upgrade Plan": "Upgrade-Plan",
"Manage billing": "Abrechnung verwalten"
},
"OnBoardingPopups": {
"Finish": "Beenden",
@ -1099,5 +1104,37 @@
"Welcome back": "Willkommen zurück",
"You can always switch sites using the account menu.": "Sie können jederzeit über das Kontomenü zwischen den Websites wechseln.",
"You have access to the following Grist sites.": "Sie haben Zugriff auf die folgenden Grist-Seiten."
},
"SupportGristNudge": {
"Support Grist": "Grist Support",
"Close": "Schließen",
"Contribute": "Beitragen",
"Help Center": "Hilfe-Center",
"Opt in to Telemetry": "Melden Sie sich für Telemetrie an",
"Opted In": "Angemeldet",
"Support Grist page": "Support Grist-Seite"
},
"SupportGristPage": {
"GitHub Sponsors page": "GitHub-Sponsorenseite",
"Help Center": "Hilfe-Center",
"Manage Sponsorship": "Sponsoring verwalten",
"Opt in to Telemetry": "Melden Sie sich für Telemetrie an",
"This instance is opted in to telemetry. Only the site administrator has permission to change this.": "Diese Instanz ist für Telemetrie aktiviert. Nur der Site-Administrator hat die Berechtigung, dies zu ändern.",
"This instance is opted out of telemetry. Only the site administrator has permission to change this.": "Diese Instanz ist von der Telemetrie deaktiviert. Nur der Site-Administrator hat die Berechtigung, dies zu ändern.",
"We only collect usage statistics, as detailed in our {{link}}, never document contents.": "Wir erfassen nur Nutzungsstatistiken, wie in unserem {{link}} beschrieben, jedoch niemals Inhalte der Dokumenten.",
"You can opt out of telemetry at any time from this page.": "Sie können die Telemetrie jederzeit auf dieser Seite deaktivieren.",
"GitHub": "GitHub",
"Home": "Home",
"Opt out of Telemetry": "Deaktivieren Sie die Telemetrie",
"Sponsor Grist Labs on GitHub": "Sponsern Sie Grist Labs auf GitHub",
"Support Grist": "Grist Support",
"Telemetry": "Telemetrie",
"You have opted in to telemetry. Thank you!": "Sie haben sich für die Telemetrie entschieden. Vielen Dank!",
"You have opted out of telemetry.": "Sie haben sich von der Telemetrie abgemeldet."
},
"buildViewSectionDom": {
"No row selected in {{title}}": "Keine Zeile in {{title}} ausgewählt",
"Not all data is shown": "Es werden nicht alle Daten angezeigt",
"No data": "Keine Daten"
}
}

@ -39,7 +39,9 @@
"View As": "View As",
"Seed rules": "Seed rules",
"When adding table rules, automatically add a rule to grant OWNER full access.": "When adding table rules, automatically add a rule to grant OWNER full access.",
"Permission to edit document structure": "Permission to edit document structure"
"Permission to edit document structure": "Permission to edit document structure",
"This default should be changed if editors' access is to be limited. ": "This default should be changed if editors' access is to be limited. ",
"Allow editors to edit structure (e.g. modify and delete tables, columns, layouts), and to write formulas, which give access to all data regardless of read restrictions.": "Allow editors to edit structure (e.g. modify and delete tables, columns, layouts), and to write formulas, which give access to all data regardless of read restrictions."
},
"AccountPage": {
"API": "API",
@ -589,7 +591,8 @@
"Send to Google Drive": "Send to Google Drive",
"Show in folder": "Show in folder",
"Unsaved": "Unsaved",
"Work on a Copy": "Work on a Copy"
"Work on a Copy": "Work on a Copy",
"Share": "Share"
},
"SiteSwitcher": {
"Create new team site": "Create new team site",
@ -801,7 +804,8 @@
"Find Next ": "Find Next ",
"Find Previous ": "Find Previous ",
"No results": "No results",
"Search in document": "Search in document"
"Search in document": "Search in document",
"Search": "Search"
},
"sendToDrive": {
"Sending file to Google Drive": "Sending file to Google Drive"
@ -979,7 +983,9 @@
"Add New": "Add New",
"Use the 𝚺 icon to create summary (or pivot) tables, for totals or subtotals.": "Use the 𝚺 icon to create summary (or pivot) tables, for totals or subtotals.",
"Anchor Links": "Anchor Links",
"Custom Widgets": "Custom Widgets"
"Custom Widgets": "Custom Widgets",
"To make an anchor link that takes the user to a specific cell, click on a row and press {{shortcut}}.": "To make an anchor link that takes the user to a specific cell, click on a row and press {{shortcut}}.",
"You can choose one of our pre-made widgets or embed your own by providing its full URL.": "You can choose one of our pre-made widgets or embed your own by providing its full URL."
},
"DescriptionConfig": {
"DESCRIPTION": "DESCRIPTION"
@ -1041,6 +1047,61 @@
"You can always switch sites using the account menu.": "You can always switch sites using the account menu.",
"You have access to the following Grist sites.": "You have access to the following Grist sites."
},
"DescriptionTextArea": {
"DESCRIPTION": "DESCRIPTION"
},
"UserManager": {
"Add {{member}} to your team": "Add {{member}} to your team",
"Allow anyone with the link to open.": "Allow anyone with the link to open.",
"Anyone with link ": "Anyone with link ",
"Cancel": "Cancel",
"Close": "Close",
"Collaborator": "Collaborator",
"Confirm": "Confirm",
"Copy Link": "Copy Link",
"Create a team to share with more people": "Create a team to share with more people",
"Grist support": "Grist support",
"Guest": "Guest",
"Invite multiple": "Invite multiple",
"Invite people to {{resourceType}}": "Invite people to {{resourceType}}",
"Link copied to clipboard": "Link copied to clipboard",
"Manage members of team site": "Manage members of team site",
"No default access allows access to be granted to individual documents or workspaces, rather than the full team site.": "No default access allows access to be granted to individual documents or workspaces, rather than the full team site.",
"Off": "Off",
"On": "On",
"Once you have removed your own access, you will not be able to get it back without assistance from someone else with sufficient access to the {{name}}.": "Once you have removed your own access, you will not be able to get it back without assistance from someone else with sufficient access to the {{name}}.",
"Open Access Rules": "Open Access Rules",
"Outside collaborator": "Outside collaborator",
"Public Access": "Public Access",
"Public access": "Public access",
"Public access inherited from {{parent}}. To remove, set 'Inherit access' option to 'None'.": "Public access inherited from {{parent}}. To remove, set 'Inherit access' option to 'None'.",
"Public access: ": "Public access: ",
"Remove my access": "Remove my access",
"Save & ": "Save & ",
"Team member": "Team member",
"User inherits permissions from {{parent})}. To remove, set 'Inherit access' option to 'None'.": "User inherits permissions from {{parent})}. To remove, set 'Inherit access' option to 'None'.",
"User may not modify their own access.": "User may not modify their own access.",
"Your role for this team site": "Your role for this team site",
"Your role for this {{resourceType}}": "Your role for this {{resourceType}}",
"free collaborator": "free collaborator",
"guest": "guest",
"member": "member",
"team site": "team site",
"{{collaborator}} limit exceeded": "{{collaborator}} limit exceeded",
"{{limitAt}} of {{limitTop}} {{collaborator}}s": "{{limitAt}} of {{limitTop}} {{collaborator}}s",
"No default access allows access to be granted to individual documents or workspaces, rather than the full team site.": "No default access allows access to be granted to individual documents or workspaces, rather than the full team site.",
"Once you have removed your own access, you will not be able to get it back without assistance from someone else with sufficient access to the {{resourceType}}.": "Once you have removed your own access, you will not be able to get it back without assistance from someone else with sufficient access to the {{resourceType}}.",
"User has view access to {{resource}} resulting from manually-set access to resources inside. If removed here, this user will lose access to resources inside.": "User has view access to {{resource}} resulting from manually-set access to resources inside. If removed here, this user will lose access to resources inside.",
"User inherits permissions from {{parent}}. To remove, set 'Inherit access' option to 'None'.": "User inherits permissions from {{parent}}. To remove, set 'Inherit access' option to 'None'.",
"You are about to remove your own access to this {{resourceType}}": "You are about to remove your own access to this {{resourceType}}"
},
"SearchModel": {
"Search all pages": "Search all pages",
"Search all tables": "Search all tables"
},
"searchDropdown": {
"Search": "Search"
},
"SupportGristNudge": {
"Close": "Close",
"Contribute": "Contribute",

@ -66,7 +66,11 @@
"Sign Out": "Cerrar Sesión",
"Sign in": "Iniciar Sesión",
"Switch Accounts": "Cambiar de Cuenta",
"Toggle Mobile Mode": "Alternar Modo Móvil"
"Toggle Mobile Mode": "Alternar Modo Móvil",
"Activation": "Activación",
"Billing Account": "Cuenta de facturación",
"Support Grist": "Soporte Grist",
"Upgrade Plan": "Actualizar el Plan"
},
"AddNewButton": {
"Add New": "Agregar Nuevo"
@ -430,7 +434,8 @@
"Notifications": "Notificaciones",
"Renew": "Renovar",
"Report a problem": "Reportar un problema",
"Upgrade Plan": "Actualizar el Plan"
"Upgrade Plan": "Actualizar el Plan",
"Manage billing": "Administrar la facturación"
},
"OnBoardingPopups": {
"Finish": "Finalizar",
@ -1089,5 +1094,37 @@
"Welcome back": "Bienvenido de nuevo",
"You can always switch sites using the account menu.": "Siempre puedes cambiar de sitio utilizando el menú de la cuenta.",
"You have access to the following Grist sites.": "Usted tiene acceso a los siguientes sitios de Grist."
},
"SupportGristNudge": {
"Help Center": "Centro de ayuda",
"Opted In": "Optado por participar",
"Support Grist": "Soporte Grist",
"Opt in to Telemetry": "Participar en Telemetría",
"Support Grist page": "Página de soporte de Grist",
"Close": "Cerrar",
"Contribute": "Contribuir"
},
"SupportGristPage": {
"GitHub": "GitHub",
"GitHub Sponsors page": "Página de patrocinadores de GitHub",
"Help Center": "Centro de ayuda",
"Manage Sponsorship": "Gestionar el patrocinio",
"Opt in to Telemetry": "Optar por la telemetría",
"Opt out of Telemetry": "Darse de baja de la telemetría",
"Telemetry": "Telemetría",
"This instance is opted in to telemetry. Only the site administrator has permission to change this.": "Esta instancia está habilitada para la telemetría. Solo el administrador del sitio tiene permiso para cambiar esto.",
"Sponsor Grist Labs on GitHub": "Patrocinar Grist Labs en GitHub",
"Support Grist": "Soporte Grist",
"This instance is opted out of telemetry. Only the site administrator has permission to change this.": "Esta instancia está inhabilitada para la telemetría. Solo el administrador del sitio tiene permiso para cambiar esto.",
"We only collect usage statistics, as detailed in our {{link}}, never document contents.": "Solo recopilamos estadísticas de uso, como se detalla en nuestro {{link}}, nunca el contenido de los documentos.",
"You can opt out of telemetry at any time from this page.": "Puede cancelar la telemetría en cualquier momento desde esta página.",
"You have opted in to telemetry. Thank you!": "Ha optado por la telemetría. ¡Gracias!",
"You have opted out of telemetry.": "Ha optado por no participar en la telemetría.",
"Home": "Inicio"
},
"buildViewSectionDom": {
"No data": "Sin datos",
"No row selected in {{title}}": "Ninguna fila seleccionada en {{title}}",
"Not all data is shown": "No se muestran todos los datos"
}
}

@ -78,7 +78,8 @@
"No notifications": "Nessuna notifica",
"Renew": "Rinnova",
"Report a problem": "Segnala un problema",
"Upgrade Plan": "Aggiorna il tuo piano"
"Upgrade Plan": "Aggiorna il tuo piano",
"Manage billing": "Gestisci modalità di addebito"
},
"Pages": {
"Delete data and this page.": "Elimina i dati e questa pagina.",
@ -345,7 +346,11 @@
"Pricing": "Prezzi",
"Profile Settings": "Impostazioni utente",
"Sign in": "Accedi",
"Switch Accounts": "Cambia account"
"Switch Accounts": "Cambia account",
"Activation": "Attivazione",
"Upgrade Plan": "Cambia il tuo piano",
"Billing Account": "Conto di addebito",
"Support Grist": "Sostieni Grist"
},
"ActionLog": {
"Column {{colId}} was subsequently removed in action #{{action.actionNum}}": "La colonna {{colId}} è stata successivamente rimossa nell'azione #{{action.actionNum}}",
@ -1035,5 +1040,37 @@
"Welcome back": "Bentornato",
"You can always switch sites using the account menu.": "Puoi sempre cambiare sito usando il menu del tuo profilo.",
"You have access to the following Grist sites.": "Hai accesso a questi siti di Grist."
},
"SupportGristNudge": {
"Support Grist page": "Pagina Sostieni Grist",
"Close": "Chiudi",
"Contribute": "Contribuisci",
"Help Center": "Centro Aiuto",
"Opt in to Telemetry": "Accetta la telemetria",
"Opted In": "Accettato",
"Support Grist": "Sostieni Grist"
},
"SupportGristPage": {
"GitHub": "GitHub",
"Help Center": "Centro Aiuto",
"Home": "Pagina iniziale",
"Manage Sponsorship": "Gestisci sponsorizzazione",
"Opt out of Telemetry": "Disattiva la telemetria",
"Sponsor Grist Labs on GitHub": "Sostieni Grist Labs su GitHub",
"Support Grist": "Sostieni Grist",
"Telemetry": "Telemetria",
"This instance is opted in to telemetry. Only the site administrator has permission to change this.": "Questa istanza accetta la telemetria. Solo un amministratore può cambiare questa impostazione.",
"This instance is opted out of telemetry. Only the site administrator has permission to change this.": "Questa istanza ha disattivato la telemetria. Solo un amministratore può cambiare questa opzione.",
"You can opt out of telemetry at any time from this page.": "Puoi disattivare la telemetria in qualsiasi momento da questa pagina.",
"You have opted in to telemetry. Thank you!": "Hai accettato la telemetria. Grazie!",
"You have opted out of telemetry.": "Hai disattivato la telemetria.",
"GitHub Sponsors page": "Pagina Sponsor GitHub",
"Opt in to Telemetry": "Accetta la telemetria",
"We only collect usage statistics, as detailed in our {{link}}, never document contents.": "Raccogliamo solo statistiche di utilizzo, mai contenuti dei documenti, come spiegato in {{link}}."
},
"buildViewSectionDom": {
"No row selected in {{title}}": "Nessuna riga selezionata in {{title}}",
"Not all data is shown": "Non tutti i dati sono mostrati",
"No data": "Nessun dato"
}
}

@ -129,7 +129,8 @@
},
"WelcomeSitePicker": {
"You can always switch sites using the account menu.": "Sempre pode alternar entre sites através do menu da conta.",
"Welcome back": "Bem-vindo de volta"
"Welcome back": "Bem-vindo de volta",
"You have access to the following Grist sites.": "Tens acesso aos seguintes sítios Grist."
},
"MakeCopyMenu": {
"Cancel": "Cancelar",

@ -71,7 +71,11 @@
"Sign Out": "Sair",
"Sign in": "Entrar",
"Switch Accounts": "Alternar Contas",
"Toggle Mobile Mode": "Alternar Modo Móvel"
"Toggle Mobile Mode": "Alternar Modo Móvel",
"Activation": "Ativação",
"Billing Account": "Conta de faturamento",
"Support Grist": "Suporte Grist",
"Upgrade Plan": "Atualizar o Plano"
},
"ActionLog": {
"Action Log failed to load": "Falha ao carregar o Log de Ações",
@ -511,7 +515,8 @@
"Notifications": "Notificações",
"Renew": "Renovar",
"Report a problem": "Reportar um problema",
"Upgrade Plan": "Atualizar o Plano"
"Upgrade Plan": "Atualizar o Plano",
"Manage billing": "Gerenciar faturamento"
},
"OnBoardingPopups": {
"Finish": "Terminar",
@ -1099,5 +1104,37 @@
"You have access to the following Grist sites.": "Você tem acesso aos seguintes sites do Grist.",
"Welcome back": "Bem-vindo de volta",
"You can always switch sites using the account menu.": "Você sempre pode alternar entre sites usando o menu da conta."
},
"SupportGristNudge": {
"Close": "Fechar",
"Opt in to Telemetry": "Aceitar a Telemetria",
"Help Center": "Centro de Ajuda",
"Support Grist": "Suporte Grist",
"Contribute": "Contribuir",
"Opted In": "Optou por participar",
"Support Grist page": "Página de Suporte Grist"
},
"SupportGristPage": {
"GitHub": "GitHub",
"GitHub Sponsors page": "Página de patrocinadores do GitHub",
"Help Center": "Centro de Ajuda",
"Home": "Início",
"Manage Sponsorship": "Gerenciar patrocínio",
"Opt in to Telemetry": "Aceitar a Telemetria",
"Opt out of Telemetry": "Desativar a Telemetria",
"Sponsor Grist Labs on GitHub": "Patrocine Grist Labs no GitHub",
"Support Grist": "Suporte Grist",
"Telemetry": "Telemetria",
"This instance is opted in to telemetry. Only the site administrator has permission to change this.": "Esta instância está incluída na telemetria. Somente o administrador do site tem permissão para alterar isso.",
"You can opt out of telemetry at any time from this page.": "Você pode desativar a telemetria a qualquer momento nesta página.",
"You have opted out of telemetry.": "Você decidiu em não participar da telemetria.",
"We only collect usage statistics, as detailed in our {{link}}, never document contents.": "Coletamos apenas estatísticas de uso, conforme detalhado em nosso {{link}}, nunca o conteúdo dos documentos.",
"This instance is opted out of telemetry. Only the site administrator has permission to change this.": "Esta instância foi desativada da telemetria. Somente o administrador do site tem permissão para alterar isso.",
"You have opted in to telemetry. Thank you!": "Você optou pela telemetria. Obrigado!"
},
"buildViewSectionDom": {
"No data": "Sem dados",
"No row selected in {{title}}": "Nenhuma linha selecionada em {{title}}",
"Not all data is shown": "Nem todos os dados são mostrados"
}
}

Loading…
Cancel
Save