diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 6a808cd5..ef1f5d7b 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -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 diff --git a/app/client/aclui/AccessRules.ts b/app/client/aclui/AccessRules.ts index d2912703..e205bce1 100644 --- a/app/client/aclui/AccessRules.ts +++ b/app/client/aclui/AccessRules.ts @@ -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'), diff --git a/app/client/components/ChartView.ts b/app/client/components/ChartView.ts index 8fd99763..7b33169b 100644 --- a/app/client/components/ChartView.ts +++ b/app/client/components/ChartView.ts @@ -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 ), ]), diff --git a/app/client/models/DocPageModel.ts b/app/client/models/DocPageModel.ts index 84aeb208..3c711e2d 100644 --- a/app/client/models/DocPageModel.ts +++ b/app/client/models/DocPageModel.ts @@ -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}) diff --git a/app/client/models/SearchModel.ts b/app/client/models/SearchModel.ts index f3525c67..2a791e04 100644 --- a/app/client/models/SearchModel.ts +++ b/app/client/models/SearchModel.ts @@ -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. diff --git a/app/client/ui/AccountPage.ts b/app/client/ui/AccountPage.ts index 88d95ad8..452c6284 100644 --- a/app/client/ui/AccountPage.ts +++ b/app/client/ui/AccountPage.ts @@ -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), ), diff --git a/app/client/ui/ApiKey.ts b/app/client/ui/ApiKey.ts index 3d6f313d..aa269bbc 100644 --- a/app/client/ui/ApiKey.ts +++ b/app/client/ui/ApiKey.ts @@ -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?" ), } ); diff --git a/app/client/ui/DocTour.ts b/app/client/ui/DocTour.ts index 86b6bb10..d1c2385f 100644 --- a/app/client/ui/DocTour.ts +++ b/app/client/ui/DocTour.ts @@ -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, }]; diff --git a/app/client/ui/ExampleInfo.ts b/app/client/ui/ExampleInfo.ts index 471b2e34..1781f628 100644 --- a/app/client/ui/ExampleInfo.ts +++ b/app/client/ui/ExampleInfo.ts @@ -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"), }, }, { diff --git a/app/client/ui/GristTooltips.ts b/app/client/ui/GristTooltips.ts index 04264845..6c997f95 100644 --- a/app/client/ui/GristTooltips.ts +++ b/app/client/ui/GristTooltips.ts @@ -50,8 +50,7 @@ export const GristTooltips: Record = { 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 = { ), 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 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 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 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 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 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 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.'))), diff --git a/app/client/ui/MakeCopyMenu.ts b/app/client/ui/MakeCopyMenu.ts index 229fcedc..6c757b00 100644 --- a/app/client/ui/MakeCopyMenu.ts +++ b/app/client/ui/MakeCopyMenu.ts @@ -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"); diff --git a/app/client/ui/ShareMenu.ts b/app/client/ui/ShareMenu.ts index a9b988ef..fe3fa365 100644 --- a/app/client/ui/ShareMenu.ts +++ b/app/client/ui/ShareMenu.ts @@ -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'), ); } diff --git a/app/client/ui/UserManager.ts b/app/client/ui/UserManager.ts index f4f55a54..2f2f55a4 100644 --- a/app/client/ui/UserManager.ts +++ b/app/client/ui/UserManager.ts @@ -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; 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; } diff --git a/app/client/ui/WelcomeTour.ts b/app/client/ui/WelcomeTour.ts index e2128642..6cafb14d 100644 --- a/app/client/ui/WelcomeTour.ts +++ b/app/client/ui/WelcomeTour.ts @@ -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, ` diff --git a/app/client/ui/errorPages.ts b/app/client/ui/errorPages.ts index a6eea10d..0f989c88 100644 --- a/app/client/ui/errorPages.ts +++ b/app/client/ui/errorPages.ts @@ -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 diff --git a/app/client/ui/searchDropdown.ts b/app/client/ui/searchDropdown.ts index 8597778c..4884ec7b 100644 --- a/app/client/ui/searchDropdown.ts +++ b/app/client/ui/searchDropdown.ts @@ -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 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)), ), diff --git a/app/client/ui2018/search.ts b/app/client/ui2018/search.ts index acb72723..37a53340 100644 --- a/app/client/ui2018/search.ts +++ b/app/client/ui2018/search.ts @@ -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( diff --git a/app/gen-server/entity/Limit.ts b/app/gen-server/entity/Limit.ts index 1011a98f..eeb40f5e 100644 --- a/app/gen-server/entity/Limit.ts +++ b/app/gen-server/entity/Limit.ts @@ -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; /** diff --git a/app/server/lib/ActiveDoc.ts b/app/server/lib/ActiveDoc.ts index bb4ce879..c37f405e 100644 --- a/app/server/lib/ActiveDoc.ts +++ b/app/server/lib/ActiveDoc.ts @@ -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 { + 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. diff --git a/app/server/lib/ActiveDocImport.ts b/app/server/lib/ActiveDocImport.ts index d257c0b6..3e86fcd5 100644 --- a/app/server/lib/ActiveDocImport.ts +++ b/app/server/lib/ActiveDocImport.ts @@ -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. + * 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 _importFiles(docSession: OptDocSession, upload: UploadInfo, transforms: TransformRuleMap[], - {parseOptions = {}, mergeOptionMaps = []}: ImportOptions, - isHidden: boolean): Promise { - - // 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} 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 { - 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 { + 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 { + + // 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} 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 { + 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. diff --git a/app/server/lib/DocStorage.ts b/app/server/lib/DocStorage.ts index d6a46834..f5658c2a 100644 --- a/app/server/lib/DocStorage.ts +++ b/app/server/lib/DocStorage.ts @@ -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 { 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)); } } diff --git a/app/server/lib/FlexServer.ts b/app/server/lib/FlexServer.ts index 72a55f13..1aa73ea2 100644 --- a/app/server/lib/FlexServer.ts +++ b/app/server/lib/FlexServer.ts @@ -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); diff --git a/app/server/lib/ICreate.ts b/app/server/lib/ICreate.ts index f876fa9c..73b76e5c 100644 --- a/app/server/lib/ICreate.ts +++ b/app/server/lib/ICreate.ts @@ -32,6 +32,8 @@ export interface ICreate { sessionSecret(): string; // Check configuration of the app early enough to show on startup. configure?(): Promise; + // Optionally perform sanity checks on the configured storage, throwing a fatal error if it is not functional + checkBackend?(): Promise; // 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?.(); diff --git a/static/locales/de.client.json b/static/locales/de.client.json index df78bbab..44e6d682 100644 --- a/static/locales/de.client.json +++ b/static/locales/de.client.json @@ -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" } } diff --git a/static/locales/en.client.json b/static/locales/en.client.json index a1504192..766dee29 100644 --- a/static/locales/en.client.json +++ b/static/locales/en.client.json @@ -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", diff --git a/static/locales/es.client.json b/static/locales/es.client.json index 0c29dbd4..6e7d51a7 100644 --- a/static/locales/es.client.json +++ b/static/locales/es.client.json @@ -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" } } diff --git a/static/locales/it.client.json b/static/locales/it.client.json index adc66fdc..43f8f251 100644 --- a/static/locales/it.client.json +++ b/static/locales/it.client.json @@ -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" } } diff --git a/static/locales/pt.client.json b/static/locales/pt.client.json index e6af54f2..a50ec89e 100644 --- a/static/locales/pt.client.json +++ b/static/locales/pt.client.json @@ -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", diff --git a/static/locales/pt_BR.client.json b/static/locales/pt_BR.client.json index 3bdc4aee..48f26cb2 100644 --- a/static/locales/pt_BR.client.json +++ b/static/locales/pt_BR.client.json @@ -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" } }