diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 646e0461..272e3f31 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -20,7 +20,7 @@ jobs: python-version: [3.9] node-version: [18.x] tests: - - ':lint:python:client:common:smoke:' + - ':lint:python:client:common:smoke:stubs:' - ':server-1-of-2:' - ':server-2-of-2:' - ':nbrowser-^[A-G]:' @@ -73,7 +73,7 @@ jobs: run: yarn run build:prod - name: Install chromedriver - if: contains(matrix.tests, ':nbrowser-') || contains(matrix.tests, ':smoke:') + if: contains(matrix.tests, ':nbrowser-') || contains(matrix.tests, ':smoke:') || contains(matrix.tests, ':stubs:') run: ./node_modules/selenium-webdriver/bin/linux/selenium-manager --driver chromedriver - name: Run smoke test @@ -92,6 +92,10 @@ jobs: if: contains(matrix.tests, ':common:') run: yarn run test:common + - name: Run stubs tests + if: contains(matrix.tests, ':stubs:') + run: MOCHA_WEBDRIVER_HEADLESS=1 yarn run test:stubs + - name: Run server tests with minio and redis if: contains(matrix.tests, ':server-') run: | diff --git a/README.md b/README.md index 9427d4e0..1509f475 100644 --- a/README.md +++ b/README.md @@ -14,19 +14,6 @@ The `grist-core`, `grist-electron`, and `grist-static` repositories are all open https://user-images.githubusercontent.com/118367/151245587-892e50a6-41f5-4b74-9786-fe3566f6b1fb.mp4 -## 2024 - We're hiring a Systems Engineer! - -We are looking for a friendly, capable engineer to join our small -team. You will have broad responsibility for the ease of installation -and maintenance of Grist as an application and service, by our -clients, by self-hosters, and by ourselves. -Read the [full job posting](https://www.getgrist.com/job-systems-engineer/) -or jump into the puzzle that comes with it by just running this: - -``` -docker run -it gristlabs/grist-twist -``` - ## Features Grist is a hybrid database/spreadsheet, meaning that: diff --git a/app/client/ui/CreateTeamModal.ts b/app/client/ui/CreateTeamModal.ts new file mode 100644 index 00000000..91ce263c --- /dev/null +++ b/app/client/ui/CreateTeamModal.ts @@ -0,0 +1,362 @@ +import {autoFocus} from 'app/client/lib/domUtils'; +import {ValidationGroup, Validator} from 'app/client/lib/Validator'; +import {AppModel, getHomeUrl} from 'app/client/models/AppModel'; +import {reportError, UserError} from 'app/client/models/errors'; +import {urlState} from 'app/client/models/gristUrlState'; +import {bigBasicButton, bigPrimaryButton, bigPrimaryButtonLink} from 'app/client/ui2018/buttons'; +import {mediaSmall, theme, vars} from 'app/client/ui2018/cssVars'; +import {icon} from 'app/client/ui2018/icons'; +import {IModalControl, modal} from 'app/client/ui2018/modals'; +import {TEAM_PLAN} from 'app/common/Features'; +import {checkSubdomainValidity} from 'app/common/orgNameUtils'; +import {UserAPIImpl} from 'app/common/UserAPI'; +import { + Disposable, dom, DomArg, DomContents, DomElementArg, IDisposableOwner, input, makeTestId, + Observable, styled +} from 'grainjs'; +import { makeT } from '../lib/localization'; + +const t = makeT('CreateTeamModal'); +const testId = makeTestId('test-create-team-'); + +export function buildNewSiteModal(context: Disposable, options: { + planName: string, + selectedPlan?: string, + onCreate?: () => void +}) { + const { onCreate } = options; + + return showModal( + context, + (_owner: Disposable, ctrl: IModalControl) => dom.create(NewSiteModalContent, ctrl, onCreate), + dom.cls(cssModalIndex.className), + ); +} + +class NewSiteModalContent extends Disposable { + private _page = Observable.create(this, 'createTeam'); + private _team = Observable.create(this, ''); + private _domain = Observable.create(this, ''); + private _ctrl: IModalControl; + + constructor( + ctrl: IModalControl, + private _onCreate?: (planName: string) => void) { + super(); + this._ctrl = ctrl; + } + + public buildDom() { + const team = this._team; + const domain = this._domain; + const ctrl = this._ctrl; + return dom.domComputed(this._page, pageValue => { + + switch (pageValue) { + case 'createTeam': return buildTeamPage({ + team, + domain, + create: () => this._createTeam(), + ctrl + }); + case 'teamSuccess': return buildConfirm({domain: domain.get()}); + } + }); + } + + private async _createTeam() { + const api = new UserAPIImpl(getHomeUrl()); + try { + await api.newOrg({name: this._team.get(), domain: this._domain.get()}); + this._page.set('teamSuccess'); + if (this._onCreate) { + this._onCreate(TEAM_PLAN); + } + } catch (err) { + reportError(err as Error); + } + } +} + +export function buildUpgradeModal(owner: Disposable, planName: string): void { + throw new UserError(t(`Billing is not supported in grist-core`)); +} + +export interface UpgradeButton { + showUpgradeCard(...args: DomArg[]): DomContents; + showUpgradeButton(...args: DomArg[]): DomContents; +} + +export function buildUpgradeButton(owner: IDisposableOwner, app: AppModel): UpgradeButton { + return { + showUpgradeCard: () => null, + showUpgradeButton: () => null, + }; +} + +export function buildConfirm({ + domain, +}: { + domain: string; +}) { + return cssConfirmWrapper( + cssSparks(), + hspace('1.5em'), + cssHeaderLine(t('Team site created'), testId("confirmation")), + hspace('2em'), + bigPrimaryButtonLink( + urlState().setLinkUrl({org: domain || undefined}), t('Go to your site'), testId("confirmation-link") + ) + ); +} + +function buildTeamPage({ + team, + domain, + create, + ctrl +}: { + team: Observable; + domain: Observable; + create: () => any; + ctrl: IModalControl; +}) { + const disabled = Observable.create(null, false); + const group = new ValidationGroup(); + async function click() { + disabled.set(true); + try { + if (!await group.validate()) { + return; + } + await create(); + } finally { + disabled.set(false); + } + } + const clickOnEnter = dom.onKeyPress({ + Enter: () => click(), + }); + return cssWide( + dom.autoDispose(disabled), + cssHeaderLine(t("Work as a Team"), testId("creation-title")), + cssSubHeaderLine(t("Choose a name and url for your team site")), + hspace('1.5em'), + cssColumns( + cssSetup( + cssLabel(t('Team name')), + cssRow(cssField(cssInput( + team, + {onInput: true}, + autoFocus(), + group.inputReset(), + clickOnEnter, + testId('name')))), + dom.create(Validator, group, t("Team name is required"), () => !!team.get()), + hspace('2em'), + cssLabel(t('Team url')), + cssRow( + {style: 'align-items: baseline'}, + cssField( + {style: 'flex: 0 1 0; min-width: auto; margin-right: 5px'}, + dom.text(`${window.location.origin}/o/`)), + cssField(cssInput( + domain, {onInput: true}, clickOnEnter, group.inputReset(), testId('domain') + )), + ), + dom.create(Validator, group, t("Domain name is required"), () => !!domain.get()), + dom.create(Validator, group, t("Domain name is invalid"), () => checkSubdomainValidity(domain.get())), + cssButtonsRow( + bigBasicButton( + t('Cancel'), + dom.on('click', () => ctrl.close()), + testId('cancel')), + bigPrimaryButton(t("Create site"), + dom.on('click', click), + dom.prop('disabled', disabled), + testId('confirm') + ), + ) + ) + ) + ); +} + +function showModal( + context: Disposable, + content: (owner: Disposable, ctrl: IModalControl) => DomContents, + ...args: DomElementArg[] +) { + let control!: IModalControl; + modal((ctrl, modalScope) => { + control = ctrl; + // When parent is being disposed and we are still visible, close the modal. + context.onDispose(() => { + // If the modal is already closed (disposed, do nothing) + if (modalScope.isDisposed()) { + return; + } + // If not, and parent is going away, close the modal. + ctrl.close(); + }); + return [ + cssCreateTeamModal.cls(''), + cssCloseButton(testId("close-modal"), cssBigIcon('CrossBig'), dom.on('click', () => ctrl.close())), + content(modalScope, ctrl) + ]; + }, {backerDomArgs: args}); + return control; +} + +function hspace(height: string) { + return dom('div', {style: `height: ${height}`}); +} + +export const cssCreateTeamModal = styled('div', ` + position: relative; + @media ${mediaSmall} { + & { + width: 100%; + min-width: unset; + padding: 24px 16px; + } + } +`); + +const cssConfirmWrapper = styled('div', ` + text-align: center; +`); + +const cssSparks = styled('div', ` + height: 48px; + width: 48px; + background-image: var(--icon-Sparks); + display: inline-block; + background-repeat: no-repeat; + &-small { + height: 20px; + width: 20px; + background-size: cover; + } +`); + +const cssColumns = styled('div', ` + display: flex; + gap: 60px; + flex-wrap: wrap; +`); + +const cssSetup = styled('div', ` + display: flex; + flex-direction: column; + flex-grow: 1; +`); + +const cssHeaderLine = styled('div', ` + text-align: center; + font-size: 24px; + font-weight: 600; + margin-bottom: 16px; +`); + +const cssSubHeaderLine = styled('div', ` + text-align: center; + margin-bottom: 7px; +`); + +const cssLabel = styled('label', ` + font-weight: ${vars.headerControlTextWeight}; + font-size: ${vars.mediumFontSize}; + color: ${theme.text}; + line-height: 1.5em; + margin: 0px; + margin-bottom: 0.3em; +`); + + +const cssWide = styled('div', ` + min-width: 760px; + @media ${mediaSmall} { + & { + min-width: unset; + } + } +`); + +const cssRow = styled('div', ` + display: flex; +`); + +const cssField = styled('div', ` + display: block; + flex: 1 1 0; + margin: 4px 0; + min-width: 120px; +`); + + +const cssButtonsRow = styled('div', ` + display: flex; + justify-content: flex-end; + margin-top: 20px; + min-width: 250px; + gap: 10px; + flex-wrap: wrap; + @media ${mediaSmall} { + & { + margin-top: 60px; + } + } +`); + +const cssCloseButton = styled('div', ` + position: absolute; + top: 8px; + right: 8px; + padding: 4px; + border-radius: 4px; + cursor: pointer; + --icon-color: ${theme.modalCloseButtonFg}; + + &:hover { + background-color: ${theme.hover}; + } +`); + +const cssBigIcon = styled(icon, ` + padding: 12px; +`); + +const cssModalIndex = styled('div', ` + z-index: ${vars.pricingModalZIndex} +`); + +const cssInput = styled(input, ` + color: ${theme.inputFg}; + background-color: ${theme.inputBg}; + font-size: ${vars.mediumFontSize}; + height: 42px; + line-height: 16px; + width: 100%; + padding: 13px; + border: 1px solid ${theme.inputBorder}; + border-radius: 3px; + outline: none; + + &-invalid { + color: ${theme.inputInvalid}; + } + + &[type=number] { + -moz-appearance: textfield; + } + &[type=number]::-webkit-inner-spin-button, + &[type=number]::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; + } + + &::placeholder { + color: ${theme.inputPlaceholderFg}; + } +`); diff --git a/app/client/ui/HomeIntro.ts b/app/client/ui/HomeIntro.ts index debe362f..9c2fc296 100644 --- a/app/client/ui/HomeIntro.ts +++ b/app/client/ui/HomeIntro.ts @@ -93,7 +93,10 @@ function makeTeamSiteIntro(homeModel: HomeModel) { cssIntroLine(t("Get started by inviting your team and creating your first Grist document.")), (!isFeatureEnabled('helpCenter') ? null : cssIntroLine( - 'Learn more in our ', helpCenterLink(), ', or find an expert via our ', sproutsProgram, '.', // TODO i18n + t( + 'Learn more in our {{helpCenterLink}}, or find an expert via our {{sproutsProgram}}.', + {helpCenterLink: helpCenterLink(), sproutsProgram} + ), testId('welcome-text') ) ), diff --git a/app/client/ui/ProductUpgradesStub.ts b/app/client/ui/ProductUpgradesStub.ts deleted file mode 100644 index 1e054f68..00000000 --- a/app/client/ui/ProductUpgradesStub.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type {AppModel} from 'app/client/models/AppModel'; -import {commonUrls} from 'app/common/gristUrls'; -import {Disposable, DomArg, DomContents, IDisposableOwner} from 'grainjs'; - -export function buildNewSiteModal(context: Disposable, options: { - planName: string, - selectedPlan?: string, - onCreate?: () => void -}) { - window.location.href = commonUrls.plans; -} - -export function buildUpgradeModal(owner: Disposable, planName: string) { - window.location.href = commonUrls.plans; -} - -export function showTeamUpgradeConfirmation(owner: Disposable) { -} - -export interface UpgradeButton { - showUpgradeCard(...args: DomArg[]): DomContents; - showUpgradeButton(...args: DomArg[]): DomContents; -} - -export function buildUpgradeButton(owner: IDisposableOwner, app: AppModel): UpgradeButton { - return { - showUpgradeCard : () => null, - showUpgradeButton : () => null, - }; -} diff --git a/app/client/ui/SupportGristNudge.ts b/app/client/ui/SupportGristNudge.ts index ae13a0b5..e35d16cf 100644 --- a/app/client/ui/SupportGristNudge.ts +++ b/app/client/ui/SupportGristNudge.ts @@ -149,8 +149,8 @@ export class SupportGristNudge extends Disposable { cssCenterAlignedHeader(t('Opted In')), cssParagraph( t( - 'Thank you! Your trust and support is greatly appreciated. ' + - 'Opt out any time from the {{link}} in the user menu.', + 'Thank you! Your trust and support is greatly appreciated.\ + Opt out any time from the {{link}} in the user menu.', {link: adminPanelLink()}, ), ), diff --git a/app/client/ui/WebhookPage.ts b/app/client/ui/WebhookPage.ts index fcf7805e..09a6a069 100644 --- a/app/client/ui/WebhookPage.ts +++ b/app/client/ui/WebhookPage.ts @@ -37,7 +37,7 @@ const WEBHOOK_COLUMNS = [ id: 'vt_webhook_fc1', colId: 'tableId', type: 'Choice', - label: 'Table', + label: t('Table'), // widgetOptions are configured later, since the choices depend // on the user tables in the document. }, @@ -45,13 +45,13 @@ const WEBHOOK_COLUMNS = [ id: 'vt_webhook_fc2', colId: 'url', type: 'Text', - label: 'URL', + label: t('URL'), }, { id: 'vt_webhook_fc3', colId: 'eventTypes', type: 'ChoiceList', - label: 'Event Types', + label: t('Event Types'), widgetOptions: JSON.stringify({ widget: 'TextBox', alignment: 'left', @@ -59,11 +59,17 @@ const WEBHOOK_COLUMNS = [ choiceOptions: {}, }), }, + { + id: 'vt_webhook_fc10', + colId: 'watchedColIdsText', + type: 'Text', + label: t('Filter for changes in these columns (semicolon-separated ids)'), + }, { id: 'vt_webhook_fc4', colId: 'enabled', type: 'Bool', - label: 'Enabled', + label: t('Enabled'), widgetOptions: JSON.stringify({ widget: 'Switch', }), @@ -72,31 +78,31 @@ const WEBHOOK_COLUMNS = [ id: 'vt_webhook_fc5', colId: 'isReadyColumn', type: 'Text', - label: 'Ready Column', + label: t('Ready Column'), }, { id: 'vt_webhook_fc6', colId: 'webhookId', type: 'Text', - label: 'Webhook Id', + label: t('Webhook Id'), }, { id: 'vt_webhook_fc7', colId: 'name', type: 'Text', - label: 'Name', + label: t('Name'), }, { id: 'vt_webhook_fc8', colId: 'memo', type: 'Text', - label: 'Memo', + label: t('Memo'), }, { id: 'vt_webhook_fc9', colId: 'status', type: 'Text', - label: 'Status', + label: t('Status'), }, ] as const; @@ -107,8 +113,8 @@ const WEBHOOK_VIEW_FIELDS: Array<(typeof WEBHOOK_COLUMNS)[number]['colId']> = [ 'name', 'memo', 'eventTypes', 'url', 'tableId', 'isReadyColumn', - 'webhookId', 'enabled', - 'status' + 'watchedColIdsText', 'webhookId', + 'enabled', 'status' ]; /** @@ -127,9 +133,9 @@ class WebhookExternalTable implements IExternalTable { public name = 'GristHidden_WebhookTable'; public initialActions = _prepareWebhookInitialActions(this.name); public saveableFields = [ - 'tableId', 'url', 'eventTypes', 'enabled', 'name', 'memo', 'isReadyColumn', + 'tableId', 'watchedColIdsText', 'url', 'eventTypes', 'enabled', 'name', 'memo', 'isReadyColumn', ]; - public webhooks: ObservableArray = observableArray([]); + public webhooks: ObservableArray = observableArray([]); public constructor(private _docApi: DocAPI) { } @@ -151,7 +157,7 @@ class WebhookExternalTable implements IExternalTable { } const colIds = new Set(getColIdsFromDocAction(d) || []); if (colIds.has('webhookId') || colIds.has('status')) { - throw new Error(`Sorry, not all fields can be edited.`); + throw new Error(t(`Sorry, not all fields can be edited.`)); } } } @@ -162,7 +168,7 @@ class WebhookExternalTable implements IExternalTable { continue; } await this._removeWebhook(rec); - reportMessage(`Removed webhook.`); + reportMessage(t(`Removed webhook.`)); } const updates = new Set(delta.updateRows); const t2 = editor; @@ -227,6 +233,7 @@ class WebhookExternalTable implements IExternalTable { for (const webhook of webhooks) { const values = _mapWebhookValues(webhook); const rowId = rowMap.get(webhook.id); + if (rowId) { toRemove.delete(rowId); actions.push( @@ -269,7 +276,12 @@ class WebhookExternalTable implements IExternalTable { private _initalizeWebhookList(webhooks: WebhookSummary[]){ this.webhooks.removeAll(); - this.webhooks.push(...webhooks); + this.webhooks.push( + ...webhooks.map(webhook => { + const uiWebhook: UIWebhookSummary = {...webhook}; + uiWebhook.fields.watchedColIdsText = webhook.fields.watchedColIds ? webhook.fields.watchedColIds.join(";") : ""; + return uiWebhook; + })); } private _getErrorString(e: ApiError): string { @@ -308,6 +320,9 @@ class WebhookExternalTable implements IExternalTable { if (fields.eventTypes) { fields.eventTypes = without(fields.eventTypes, 'L'); } + fields.watchedColIds = fields.watchedColIdsText + ? fields.watchedColIdsText.split(";").filter((colId: string) => colId.trim() !== "") + : []; return fields; } } @@ -355,12 +370,12 @@ export class WebhookPage extends DisposableWithEvents { public async reset() { await this.docApi.flushWebhooks(); - reportSuccess('Cleared webhook queue.'); + reportSuccess(t('Cleared webhook queue.')); } public async resetSelected(id: string) { await this.docApi.flushWebhook(id); - reportSuccess(`Cleared webhook ${id} queue.`); + reportSuccess(t(`Cleared webhook ${id} queue.`)); } } @@ -440,16 +455,21 @@ function _prepareWebhookInitialActions(tableId: string): DocAction[] { /** * Map a webhook summary to a webhook table raw record. The main * difference is that `eventTypes` is tweaked to be in a cell format, - * and `status` is converted to a string. + * `status` is converted to a string, + * and `watchedColIdsText` is converted to list in a cell format. */ -function _mapWebhookValues(webhookSummary: WebhookSummary): Partial { +function _mapWebhookValues(webhookSummary: UIWebhookSummary): Partial { const fields = webhookSummary.fields; - const {eventTypes} = fields; + const {eventTypes, watchedColIdsText} = fields; + const watchedColIds = watchedColIdsText + ? watchedColIdsText.split(";").filter(colId => colId.trim() !== "") + : []; return { ...fields, webhookId: webhookSummary.id, status: JSON.stringify(webhookSummary.usage), eventTypes: [GristObjCode.List, ...eventTypes], + watchedColIds: [GristObjCode.List, ...watchedColIds], }; } @@ -457,6 +477,11 @@ type WebhookSchemaType = { [prop in keyof WebhookSummary['fields']]: WebhookSummary['fields'][prop] } & { eventTypes: [GristObjCode, ...unknown[]]; + watchedColIds: [GristObjCode, ...unknown[]]; status: string; webhookId: string; } + +type UIWebhookSummary = WebhookSummary & { + fields: {watchedColIdsText?: string;} +} diff --git a/app/common/Triggers-ti.ts b/app/common/Triggers-ti.ts index 2489181d..bb04bbae 100644 --- a/app/common/Triggers-ti.ts +++ b/app/common/Triggers-ti.ts @@ -16,6 +16,7 @@ export const WebhookFields = t.iface([], { "url": "string", "eventTypes": t.array(t.union(t.lit("add"), t.lit("update"))), "tableId": "string", + "watchedColIds": t.opt(t.array("string")), "enabled": t.opt("boolean"), "isReadyColumn": t.opt(t.union("string", "null")), "name": t.opt("string"), @@ -29,6 +30,7 @@ export const WebhookStatus = t.union(t.lit('idle'), t.lit('sending'), t.lit('ret export const WebhookSubscribe = t.iface([], { "url": "string", "eventTypes": t.array(t.union(t.lit("add"), t.lit("update"))), + "watchedColIds": t.opt(t.array("string")), "enabled": t.opt("boolean"), "isReadyColumn": t.opt(t.union("string", "null")), "name": t.opt("string"), @@ -47,6 +49,7 @@ export const WebhookSummary = t.iface([], { "eventTypes": t.array("string"), "isReadyColumn": t.union("string", "null"), "tableId": "string", + "watchedColIds": t.opt(t.array("string")), "enabled": "boolean", "name": "string", "memo": "string", @@ -63,6 +66,7 @@ export const WebhookPatch = t.iface([], { "url": t.opt("string"), "eventTypes": t.opt(t.array(t.union(t.lit("add"), t.lit("update")))), "tableId": t.opt("string"), + "watchedColIds": t.opt(t.array("string")), "enabled": t.opt("boolean"), "isReadyColumn": t.opt(t.union("string", "null")), "name": t.opt("string"), diff --git a/app/common/Triggers.ts b/app/common/Triggers.ts index 5a822f11..d3b492d6 100644 --- a/app/common/Triggers.ts +++ b/app/common/Triggers.ts @@ -10,6 +10,7 @@ export interface WebhookFields { url: string; eventTypes: Array<"add"|"update">; tableId: string; + watchedColIds?: string[]; enabled?: boolean; isReadyColumn?: string|null; name?: string; @@ -26,6 +27,7 @@ export type WebhookStatus = 'idle'|'sending'|'retrying'|'postponed'|'error'|'inv export interface WebhookSubscribe { url: string; eventTypes: Array<"add"|"update">; + watchedColIds?: string[]; enabled?: boolean; isReadyColumn?: string|null; name?: string; @@ -44,6 +46,7 @@ export interface WebhookSummary { eventTypes: string[]; isReadyColumn: string|null; tableId: string; + watchedColIds?: string[]; enabled: boolean; name: string; memo: string; @@ -63,6 +66,7 @@ export interface WebhookPatch { url?: string; eventTypes?: Array<"add"|"update">; tableId?: string; + watchedColIds?: string[]; enabled?: boolean; isReadyColumn?: string|null; name?: string; diff --git a/app/common/schema.ts b/app/common/schema.ts index 996e457e..dc1fb562 100644 --- a/app/common/schema.ts +++ b/app/common/schema.ts @@ -4,7 +4,7 @@ import { GristObjCode } from "app/plugin/GristData"; // tslint:disable:object-literal-key-quotes -export const SCHEMA_VERSION = 41; +export const SCHEMA_VERSION = 42; export const schema = { @@ -167,6 +167,8 @@ export const schema = { label : "Text", memo : "Text", enabled : "Bool", + watchedColRefList : "RefList:_grist_Tables_column", + options : "Text", }, "_grist_ACLRules": { @@ -388,6 +390,8 @@ export interface SchemaTypes { label: string; memo: string; enabled: boolean; + watchedColRefList: [GristObjCode.List, ...number[]]|null; + options: string; }; "_grist_ACLRules": { diff --git a/app/gen-server/lib/DocWorkerMap.ts b/app/gen-server/lib/DocWorkerMap.ts index a22e45a5..3a40a947 100644 --- a/app/gen-server/lib/DocWorkerMap.ts +++ b/app/gen-server/lib/DocWorkerMap.ts @@ -24,6 +24,9 @@ const CHECKSUM_TTL_MSEC = 24 * 60 * 60 * 1000; // 24 hours // How long do permits stored in redis last, in milliseconds. const PERMIT_TTL_MSEC = 1 * 60 * 1000; // 1 minute +// Default doc worker group. +const DEFAULT_GROUP = 'default'; + class DummyDocWorkerMap implements IDocWorkerMap { private _worker?: DocWorkerInfo; private _available: boolean = false; @@ -62,6 +65,10 @@ class DummyDocWorkerMap implements IDocWorkerMap { this._available = available; } + public async isWorkerRegistered(workerInfo: DocWorkerInfo): Promise { + return Promise.resolve(true); + } + public async releaseAssignment(workerId: string, docId: string): Promise { // nothing to do } @@ -241,7 +248,7 @@ export class DocWorkerMap implements IDocWorkerMap { try { // Drop out of available set first. await this._client.sremAsync('workers-available', workerId); - const group = await this._client.getAsync(`worker-${workerId}-group`) || 'default'; + const group = await this._client.getAsync(`worker-${workerId}-group`) || DEFAULT_GROUP; await this._client.sremAsync(`workers-available-${group}`, workerId); // At this point, this worker should no longer be receiving new doc assignments, though // clients may still be directed to the worker. @@ -290,7 +297,7 @@ export class DocWorkerMap implements IDocWorkerMap { public async setWorkerAvailability(workerId: string, available: boolean): Promise { log.info(`DocWorkerMap.setWorkerAvailability ${workerId} ${available}`); - const group = await this._client.getAsync(`worker-${workerId}-group`) || 'default'; + const group = await this._client.getAsync(`worker-${workerId}-group`) || DEFAULT_GROUP; if (available) { const docWorker = await this._client.hgetallAsync(`worker-${workerId}`) as DocWorkerInfo|null; if (!docWorker) { throw new Error('no doc worker contact info available'); } @@ -306,6 +313,11 @@ export class DocWorkerMap implements IDocWorkerMap { } } + public async isWorkerRegistered(workerInfo: DocWorkerInfo): Promise { + const group = workerInfo.group || DEFAULT_GROUP; + return Boolean(await this._client.sismemberAsync(`workers-available-${group}`, workerInfo.id)); + } + public async releaseAssignment(workerId: string, docId: string): Promise { const op = this._client.multi(); op.del(`doc-${docId}`); @@ -352,7 +364,7 @@ export class DocWorkerMap implements IDocWorkerMap { if (docId === 'import') { const lock = await this._redlock.lock(`workers-lock`, LOCK_TIMEOUT); try { - const _workerId = await this._client.srandmemberAsync(`workers-available-default`); + const _workerId = await this._client.srandmemberAsync(`workers-available-${DEFAULT_GROUP}`); if (!_workerId) { throw new Error('no doc worker available'); } const docWorker = await this._client.hgetallAsync(`worker-${_workerId}`) as DocWorkerInfo|null; if (!docWorker) { throw new Error('no doc worker contact info available'); } @@ -383,7 +395,7 @@ export class DocWorkerMap implements IDocWorkerMap { if (!workerId) { // Check if document has a preferred worker group set. - const group = await this._client.getAsync(`doc-${docId}-group`) || 'default'; + const group = await this._client.getAsync(`doc-${docId}-group`) || DEFAULT_GROUP; // Let's start off by assigning documents to available workers randomly. // TODO: use a smarter algorithm. diff --git a/app/server/lib/DocApi.ts b/app/server/lib/DocApi.ts index 8f01df16..5d6c01c5 100644 --- a/app/server/lib/DocApi.ts +++ b/app/server/lib/DocApi.ts @@ -392,7 +392,7 @@ export class DocWorkerApi { const tablesTable = activeDoc.docData!.getMetaTable("_grist_Tables"); const trigger = webhookId ? activeDoc.triggers.getWebhookTriggerRecord(webhookId) : undefined; let currentTableId = trigger ? tablesTable.getValue(trigger.tableRef, 'tableId')! : undefined; - const {url, eventTypes, isReadyColumn, name} = webhook; + const {url, eventTypes, watchedColIds, isReadyColumn, name} = webhook; const tableId = await getRealTableId(req.params.tableId || webhook.tableId, {metaTables}); const fields: Partial = {}; @@ -409,6 +409,23 @@ export class DocWorkerApi { } if (tableId !== undefined) { + if (watchedColIds) { + if (tableId !== currentTableId && currentTableId) { + // if the tableId changed, we need to reset the watchedColIds + fields.watchedColRefList = [GristObjCode.List]; + } else { + if (!tableId) { + throw new ApiError(`Cannot find columns "${watchedColIds}" because table is not known`, 404); + } + fields.watchedColRefList = [GristObjCode.List, ...watchedColIds + .filter(colId => colId.trim() !== "") + .map( + colId => { return colIdToReference(metaTables, tableId, colId.trim().replace(/^\$/, '')); } + )]; + } + } else { + fields.watchedColRefList = [GristObjCode.List]; + } fields.tableRef = tableIdToRef(metaTables, tableId); currentTableId = tableId; } @@ -910,7 +927,6 @@ export class DocWorkerApi { const docId = activeDoc.docName; const webhookId = req.params.webhookId; const {fields, url} = await getWebhookSettings(activeDoc, req, webhookId, req.body); - if (fields.enabled === false) { await activeDoc.triggers.clearSingleWebhookQueue(webhookId); } diff --git a/app/server/lib/DocWorkerMap.ts b/app/server/lib/DocWorkerMap.ts index 870b1b0f..922a8d1f 100644 --- a/app/server/lib/DocWorkerMap.ts +++ b/app/server/lib/DocWorkerMap.ts @@ -6,7 +6,7 @@ import { IChecksumStore } from 'app/server/lib/IChecksumStore'; import { IElectionStore } from 'app/server/lib/IElectionStore'; import { IPermitStores } from 'app/server/lib/Permit'; -import {RedisClient} from 'redis'; +import { RedisClient } from 'redis'; export interface DocWorkerInfo { id: string; @@ -57,6 +57,8 @@ export interface IDocWorkerMap extends IPermitStores, IElectionStore, IChecksumS // release existing assignments. setWorkerAvailability(workerId: string, available: boolean): Promise; + isWorkerRegistered(workerInfo: DocWorkerInfo): Promise; + // Releases doc from worker, freeing it to be assigned elsewhere. // Assignments should only be released for workers that are now unavailable. releaseAssignment(workerId: string, docId: string): Promise; diff --git a/app/server/lib/FlexServer.ts b/app/server/lib/FlexServer.ts index 33a10c20..d3d51231 100644 --- a/app/server/lib/FlexServer.ts +++ b/app/server/lib/FlexServer.ts @@ -452,7 +452,8 @@ export class FlexServer implements GristServer { // /status/hooks allows the tests to wait for them to be ready. // If db=1 query parameter is included, status will include the status of DB connection. // If redis=1 query parameter is included, status will include the status of the Redis connection. - // If ready=1 query parameter is included, status will include whether the server is fully ready. + // If docWorkerRegistered=1 query parameter is included, status will include the status of the + // doc worker registration in Redis. this.app.get('/status(/hooks)?', async (req, res) => { const checks = new Map|boolean>(); const timeout = optIntegerParam(req.query.timeout, 'timeout') || 10_000; @@ -474,6 +475,20 @@ export class FlexServer implements GristServer { if (isParameterOn(req.query.redis)) { checks.set('redis', asyncCheck(this._docWorkerMap.getRedisClient()?.pingAsync())); } + if (isParameterOn(req.query.docWorkerRegistered) && this.worker) { + // Only check whether the doc worker is registered if we have a worker. + // The Redis client may not be connected, but in this case this has to + // be checked with the 'redis' parameter (the user may want to avoid + // removing workers when connection is unstable). + if (this._docWorkerMap.getRedisClient()?.connected) { + checks.set('docWorkerRegistered', asyncCheck( + this._docWorkerMap.isWorkerRegistered(this.worker).then(isRegistered => { + if (!isRegistered) { throw new Error('doc worker not registered'); } + return isRegistered; + }) + )); + } + } if (isParameterOn(req.query.ready)) { checks.set('ready', this._isReady); } diff --git a/app/server/lib/Triggers.ts b/app/server/lib/Triggers.ts index 561e4eee..c90ee548 100644 --- a/app/server/lib/Triggers.ts +++ b/app/server/lib/Triggers.ts @@ -277,6 +277,7 @@ export class DocTriggers { // Webhook might have been deleted in the mean time. continue; } + const decodedWatchedColRefList = decodeObject(t.watchedColRefList) as number[] || []; // Report some basic info and usage stats. const entry: WebhookSummary = { // Id of the webhook @@ -288,6 +289,7 @@ export class DocTriggers { // Other fields used to register this webhook. eventTypes: decodeObject(t.eventTypes) as string[], isReadyColumn: getColId(t.isReadyColRef) ?? null, + watchedColIds: decodedWatchedColRefList.map((columnRef) => getColId(columnRef)), tableId: getTableId(t.tableRef) ?? null, // For future use - for now every webhook is enabled. enabled: t.enabled, @@ -509,6 +511,21 @@ export class DocTriggers { } } + if (trigger.watchedColRefList) { + for (const colRef of trigger.watchedColRefList.slice(1)) { + if (!this._validateColId(colRef as number, trigger.tableRef)) { + // column does not belong to table, let's ignore trigger and log stats + for (const action of webhookActions) { + const colId = this._getColId(colRef as number); // no validation + const tableId = this._getTableId(trigger.tableRef); + const error = `column is not valid: colId ${colId} does not belong to ${tableId}`; + this._stats.logInvalid(action.id, error).catch(e => log.error("Webhook stats failed to log", e)); + } + continue; + } + } + } + // TODO: would be worth checking that the trigger's fields are valid (ie: eventTypes, url, // ...) as there's no guarantee that they are. @@ -585,9 +602,21 @@ export class DocTriggers { } } + const colIdsToCheck: Array = []; + if (trigger.watchedColRefList) { + for (const colRef of trigger.watchedColRefList.slice(1)) { + colIdsToCheck.push(this._getColId(colRef as number)!); + } + } + let eventType: EventType; if (readyBefore) { - eventType = "update"; + // check if any of the columns to check were changed to consider this an update + if (colIdsToCheck.length === 0 || colIdsToCheck.some(colId => tableDelta.columnDeltas[colId]?.[rowId])) { + eventType = "update"; + } else { + return false; + } // If we allow subscribing to deletion in the future // if (recordDelta.existedAfter) { // eventType = "update"; diff --git a/app/server/lib/initialDocSql.ts b/app/server/lib/initialDocSql.ts index 0b57a95d..48c22a3b 100644 --- a/app/server/lib/initialDocSql.ts +++ b/app/server/lib/initialDocSql.ts @@ -6,7 +6,7 @@ export const GRIST_DOC_SQL = ` PRAGMA foreign_keys=OFF; BEGIN TRANSACTION; CREATE TABLE IF NOT EXISTS "_grist_DocInfo" (id INTEGER PRIMARY KEY, "docId" TEXT DEFAULT '', "peers" TEXT DEFAULT '', "basketId" TEXT DEFAULT '', "schemaVersion" INTEGER DEFAULT 0, "timezone" TEXT DEFAULT '', "documentSettings" TEXT DEFAULT ''); -INSERT INTO _grist_DocInfo VALUES(1,'','','',41,'',''); +INSERT INTO _grist_DocInfo VALUES(1,'','','',42,'',''); CREATE TABLE IF NOT EXISTS "_grist_Tables" (id INTEGER PRIMARY KEY, "tableId" TEXT DEFAULT '', "primaryViewId" INTEGER DEFAULT 0, "summarySourceTable" INTEGER DEFAULT 0, "onDemand" BOOLEAN DEFAULT 0, "rawViewSectionRef" INTEGER DEFAULT 0, "recordCardViewSectionRef" INTEGER DEFAULT 0); CREATE TABLE IF NOT EXISTS "_grist_Tables_column" (id INTEGER PRIMARY KEY, "parentId" INTEGER DEFAULT 0, "parentPos" NUMERIC DEFAULT 1e999, "colId" TEXT DEFAULT '', "type" TEXT DEFAULT '', "widgetOptions" TEXT DEFAULT '', "isFormula" BOOLEAN DEFAULT 0, "formula" TEXT DEFAULT '', "label" TEXT DEFAULT '', "description" TEXT DEFAULT '', "untieColIdFromLabel" BOOLEAN DEFAULT 0, "summarySourceCol" INTEGER DEFAULT 0, "displayCol" INTEGER DEFAULT 0, "visibleCol" INTEGER DEFAULT 0, "rules" TEXT DEFAULT NULL, "recalcWhen" INTEGER DEFAULT 0, "recalcDeps" TEXT DEFAULT NULL); CREATE TABLE IF NOT EXISTS "_grist_Imports" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "origFileName" TEXT DEFAULT '', "parseFormula" TEXT DEFAULT '', "delimiter" TEXT DEFAULT '', "doublequote" BOOLEAN DEFAULT 0, "escapechar" TEXT DEFAULT '', "quotechar" TEXT DEFAULT '', "skipinitialspace" BOOLEAN DEFAULT 0, "encoding" TEXT DEFAULT '', "hasHeaders" BOOLEAN DEFAULT 0); @@ -22,7 +22,7 @@ CREATE TABLE IF NOT EXISTS "_grist_Views_section_field" (id INTEGER PRIMARY KEY, CREATE TABLE IF NOT EXISTS "_grist_Validations" (id INTEGER PRIMARY KEY, "formula" TEXT DEFAULT '', "name" TEXT DEFAULT '', "tableRef" INTEGER DEFAULT 0); CREATE TABLE IF NOT EXISTS "_grist_REPL_Hist" (id INTEGER PRIMARY KEY, "code" TEXT DEFAULT '', "outputText" TEXT DEFAULT '', "errorText" TEXT DEFAULT ''); CREATE TABLE IF NOT EXISTS "_grist_Attachments" (id INTEGER PRIMARY KEY, "fileIdent" TEXT DEFAULT '', "fileName" TEXT DEFAULT '', "fileType" TEXT DEFAULT '', "fileSize" INTEGER DEFAULT 0, "fileExt" TEXT DEFAULT '', "imageHeight" INTEGER DEFAULT 0, "imageWidth" INTEGER DEFAULT 0, "timeDeleted" DATETIME DEFAULT NULL, "timeUploaded" DATETIME DEFAULT NULL); -CREATE TABLE IF NOT EXISTS "_grist_Triggers" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "eventTypes" TEXT DEFAULT NULL, "isReadyColRef" INTEGER DEFAULT 0, "actions" TEXT DEFAULT '', "label" TEXT DEFAULT '', "memo" TEXT DEFAULT '', "enabled" BOOLEAN DEFAULT 0); +CREATE TABLE IF NOT EXISTS "_grist_Triggers" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "eventTypes" TEXT DEFAULT NULL, "isReadyColRef" INTEGER DEFAULT 0, "actions" TEXT DEFAULT '', "label" TEXT DEFAULT '', "memo" TEXT DEFAULT '', "enabled" BOOLEAN DEFAULT 0, "watchedColRefList" TEXT DEFAULT NULL, "options" TEXT DEFAULT ''); CREATE TABLE IF NOT EXISTS "_grist_ACLRules" (id INTEGER PRIMARY KEY, "resource" INTEGER DEFAULT 0, "permissions" INTEGER DEFAULT 0, "principals" TEXT DEFAULT '', "aclFormula" TEXT DEFAULT '', "aclColumn" INTEGER DEFAULT 0, "aclFormulaParsed" TEXT DEFAULT '', "permissionsText" TEXT DEFAULT '', "rulePos" NUMERIC DEFAULT 1e999, "userAttributes" TEXT DEFAULT '', "memo" TEXT DEFAULT ''); INSERT INTO _grist_ACLRules VALUES(1,1,63,'[1]','',0,'','',1e999,'',''); CREATE TABLE IF NOT EXISTS "_grist_ACLResources" (id INTEGER PRIMARY KEY, "tableId" TEXT DEFAULT '', "colIds" TEXT DEFAULT ''); @@ -44,7 +44,7 @@ export const GRIST_DOC_WITH_TABLE1_SQL = ` PRAGMA foreign_keys=OFF; BEGIN TRANSACTION; CREATE TABLE IF NOT EXISTS "_grist_DocInfo" (id INTEGER PRIMARY KEY, "docId" TEXT DEFAULT '', "peers" TEXT DEFAULT '', "basketId" TEXT DEFAULT '', "schemaVersion" INTEGER DEFAULT 0, "timezone" TEXT DEFAULT '', "documentSettings" TEXT DEFAULT ''); -INSERT INTO _grist_DocInfo VALUES(1,'','','',41,'',''); +INSERT INTO _grist_DocInfo VALUES(1,'','','',42,'',''); CREATE TABLE IF NOT EXISTS "_grist_Tables" (id INTEGER PRIMARY KEY, "tableId" TEXT DEFAULT '', "primaryViewId" INTEGER DEFAULT 0, "summarySourceTable" INTEGER DEFAULT 0, "onDemand" BOOLEAN DEFAULT 0, "rawViewSectionRef" INTEGER DEFAULT 0, "recordCardViewSectionRef" INTEGER DEFAULT 0); INSERT INTO _grist_Tables VALUES(1,'Table1',1,0,0,2,3); CREATE TABLE IF NOT EXISTS "_grist_Tables_column" (id INTEGER PRIMARY KEY, "parentId" INTEGER DEFAULT 0, "parentPos" NUMERIC DEFAULT 1e999, "colId" TEXT DEFAULT '', "type" TEXT DEFAULT '', "widgetOptions" TEXT DEFAULT '', "isFormula" BOOLEAN DEFAULT 0, "formula" TEXT DEFAULT '', "label" TEXT DEFAULT '', "description" TEXT DEFAULT '', "untieColIdFromLabel" BOOLEAN DEFAULT 0, "summarySourceCol" INTEGER DEFAULT 0, "displayCol" INTEGER DEFAULT 0, "visibleCol" INTEGER DEFAULT 0, "rules" TEXT DEFAULT NULL, "recalcWhen" INTEGER DEFAULT 0, "recalcDeps" TEXT DEFAULT NULL); @@ -80,7 +80,7 @@ INSERT INTO _grist_Views_section_field VALUES(9,3,9,4,0,'',0,0,'',NULL); CREATE TABLE IF NOT EXISTS "_grist_Validations" (id INTEGER PRIMARY KEY, "formula" TEXT DEFAULT '', "name" TEXT DEFAULT '', "tableRef" INTEGER DEFAULT 0); CREATE TABLE IF NOT EXISTS "_grist_REPL_Hist" (id INTEGER PRIMARY KEY, "code" TEXT DEFAULT '', "outputText" TEXT DEFAULT '', "errorText" TEXT DEFAULT ''); CREATE TABLE IF NOT EXISTS "_grist_Attachments" (id INTEGER PRIMARY KEY, "fileIdent" TEXT DEFAULT '', "fileName" TEXT DEFAULT '', "fileType" TEXT DEFAULT '', "fileSize" INTEGER DEFAULT 0, "fileExt" TEXT DEFAULT '', "imageHeight" INTEGER DEFAULT 0, "imageWidth" INTEGER DEFAULT 0, "timeDeleted" DATETIME DEFAULT NULL, "timeUploaded" DATETIME DEFAULT NULL); -CREATE TABLE IF NOT EXISTS "_grist_Triggers" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "eventTypes" TEXT DEFAULT NULL, "isReadyColRef" INTEGER DEFAULT 0, "actions" TEXT DEFAULT '', "label" TEXT DEFAULT '', "memo" TEXT DEFAULT '', "enabled" BOOLEAN DEFAULT 0); +CREATE TABLE IF NOT EXISTS "_grist_Triggers" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "eventTypes" TEXT DEFAULT NULL, "isReadyColRef" INTEGER DEFAULT 0, "actions" TEXT DEFAULT '', "label" TEXT DEFAULT '', "memo" TEXT DEFAULT '', "enabled" BOOLEAN DEFAULT 0, "watchedColRefList" TEXT DEFAULT NULL, "options" TEXT DEFAULT ''); CREATE TABLE IF NOT EXISTS "_grist_ACLRules" (id INTEGER PRIMARY KEY, "resource" INTEGER DEFAULT 0, "permissions" INTEGER DEFAULT 0, "principals" TEXT DEFAULT '', "aclFormula" TEXT DEFAULT '', "aclColumn" INTEGER DEFAULT 0, "aclFormulaParsed" TEXT DEFAULT '', "permissionsText" TEXT DEFAULT '', "rulePos" NUMERIC DEFAULT 1e999, "userAttributes" TEXT DEFAULT '', "memo" TEXT DEFAULT ''); INSERT INTO _grist_ACLRules VALUES(1,1,63,'[1]','',0,'','',1e999,'',''); CREATE TABLE IF NOT EXISTS "_grist_ACLResources" (id INTEGER PRIMARY KEY, "tableId" TEXT DEFAULT '', "colIds" TEXT DEFAULT ''); diff --git a/app/server/lib/requestUtils.ts b/app/server/lib/requestUtils.ts index e7cd3a10..6424d6d9 100644 --- a/app/server/lib/requestUtils.ts +++ b/app/server/lib/requestUtils.ts @@ -103,6 +103,12 @@ export function allowHost(req: IncomingMessage, allowedHost: string|URL) { const proto = getEndUserProtocol(req); const actualUrl = new URL(getOriginUrl(req)); const allowedUrl = (typeof allowedHost === 'string') ? new URL(`${proto}://${allowedHost}`) : allowedHost; + log.rawDebug('allowHost: ', { + req: (new URL(req.url!, `http://${req.headers.host}`).href), + origin: req.headers.origin, + actualUrl: actualUrl.hostname, + allowedUrl: allowedUrl.hostname, + }); if ((req as RequestWithOrg).isCustomHost) { // For a request to a custom domain, the full hostname must match. return actualUrl.hostname === allowedUrl.hostname; diff --git a/app/server/lib/uploads.ts b/app/server/lib/uploads.ts index a3eb4582..f4170da2 100644 --- a/app/server/lib/uploads.ts +++ b/app/server/lib/uploads.ts @@ -409,6 +409,12 @@ export async function fetchDoc(server: GristServer, docId: string, req: Request, // Prepare headers that preserve credentials of current user. const headers = getTransitiveHeaders(req); + // Passing the Origin header would serve no purpose here, as we are + // constructing an internal request to fetch from our own doc worker + // URL. Indeed, it may interfere, as it could incur a CORS check in + // `trustOrigin`, which we do not need. + delete headers.Origin; + // Find the doc worker responsible for the document we wish to copy. // The backend needs to be well configured for this to work. const homeUrl = server.getHomeUrl(req); diff --git a/buildtools/webpack.config.js b/buildtools/webpack.config.js index 297c27fe..92e2c1dd 100644 --- a/buildtools/webpack.config.js +++ b/buildtools/webpack.config.js @@ -56,6 +56,7 @@ module.exports = { ], fallback: { 'path': require.resolve("path-browserify"), + 'process': require.resolve("process/browser"), }, }, module: { @@ -79,7 +80,7 @@ module.exports = { plugins: [ // Some modules assume presence of Buffer and process. new ProvidePlugin({ - process: 'process/browser', + process: 'process', Buffer: ['buffer', 'Buffer'] }), // To strip all locales except “en” diff --git a/package.json b/package.json index 7b5207d6..371f14a8 100644 --- a/package.json +++ b/package.json @@ -13,12 +13,13 @@ "install:python3": "buildtools/prepare_python3.sh", "build:prod": "buildtools/build.sh", "start:prod": "sandbox/run.sh", - "test": "GRIST_SESSION_COOKIE=grist_test_cookie GRIST_TEST_LOGIN=1 TEST_SUPPORT_API_KEY=api_key_for_support TEST_CLEAN_DATABASE=true LANGUAGE=en_US mocha ${DEBUG:+-b --no-exit} --slow 8000 $([ -z $DEBUG ] && echo --forbid-only) -g \"${GREP_TESTS}\" '_build/test/common/*.js' '_build/test/client/*.js' '_build/test/nbrowser/*.js' '_build/test/server/**/*.js' '_build/test/gen-server/**/*.js'", + "test": "GRIST_SESSION_COOKIE=grist_test_cookie GRIST_TEST_LOGIN=1 TEST_SUPPORT_API_KEY=api_key_for_support TEST_CLEAN_DATABASE=true LANGUAGE=en_US mocha ${DEBUG:+-b --no-exit} --slow 8000 $([ -z $DEBUG ] && echo --forbid-only) -g \"${GREP_TESTS}\" '_build/test/common/*.js' '_build/test/client/*.js' '_build/test/nbrowser/*.js' '_build/test/nbrowser_with_stubs/**/*.js' '_build/test/server/**/*.js' '_build/test/gen-server/**/*.js'", "test:nbrowser": "TEST_SUITE=nbrowser TEST_SUITE_FOR_TIMINGS=nbrowser TIMINGS_FILE=test/timings/nbrowser.txt GRIST_SESSION_COOKIE=grist_test_cookie GRIST_TEST_LOGIN=1 TEST_SUPPORT_API_KEY=api_key_for_support TEST_CLEAN_DATABASE=true LANGUAGE=en_US mocha ${DEBUG:+-b --no-exit} $([ -z $DEBUG ] && echo --forbid-only) -g \"${GREP_TESTS}\" --slow 8000 -R test/xunit-file '_build/test/nbrowser/**/*.js'", + "test:stubs": "GRIST_SESSION_COOKIE=grist_test_cookie GRIST_TEST_LOGIN=1 TEST_SUPPORT_API_KEY=api_key_for_support TEST_CLEAN_DATABASE=true LANGUAGE=en_US mocha ${DEBUG:+-b --no-exit} $([ -z $DEBUG ] && echo --forbid-only) -g \"${GREP_TESTS}\" --slow 8000 -R test/xunit-file '_build/test/nbrowser_with_stubs/**/*.js'", "test:client": "GRIST_SESSION_COOKIE=grist_test_cookie mocha ${DEBUG:+'-b'} '_build/test/client/**/*.js'", "test:common": "GRIST_SESSION_COOKIE=grist_test_cookie mocha ${DEBUG:+'-b'} '_build/test/common/**/*.js'", "test:server": "TEST_CLEAN_DATABASE=true TEST_SUITE=server TEST_SUITE_FOR_TIMINGS=server TIMINGS_FILE=test/timings/server.txt GRIST_SESSION_COOKIE=grist_test_cookie mocha ${DEBUG:+'-b'} -g \"${GREP_TESTS}\" -R test/xunit-file '_build/test/server/**/*.js' '_build/test/gen-server/**/*.js'", - "test:smoke": "mocha _build/test/nbrowser/Smoke.js", + "test:smoke": "LANGUAGE=en_US mocha _build/test/nbrowser/Smoke.js", "test:docker": "./test/test_under_docker.sh", "test:python": "sandbox_venv3/bin/python sandbox/grist/runtests.py ${GREP_TESTS:+discover -p \"test*${GREP_TESTS}*.py\"}", "cli": "NODE_PATH=_build:_build/stubs:_build/ext node _build/app/server/companion.js", @@ -76,7 +77,7 @@ "@types/redlock": "3.0.2", "@types/saml2-js": "2.0.1", "@types/selenium-webdriver": "4.1.15", - "@types/sinon": "5.0.5", + "@types/sinon": "17.0.3", "@types/sqlite3": "3.1.6", "@types/swagger-ui": "3.52.4", "@types/tmp": "0.0.33", @@ -100,7 +101,7 @@ "nodemon": "^2.0.4", "otplib": "12.0.1", "proper-lockfile": "4.1.2", - "sinon": "7.1.1", + "sinon": "17.0.1", "source-map-loader": "^0.2.4", "tmp-promise": "1.0.5", "ts-interface-builder": "0.3.2", diff --git a/sandbox/grist/migrations.py b/sandbox/grist/migrations.py index b6d7c42c..517b84bd 100644 --- a/sandbox/grist/migrations.py +++ b/sandbox/grist/migrations.py @@ -1307,3 +1307,13 @@ def migration41(tdset): ] return tdset.apply_doc_actions(doc_actions) + +@migration(schema_version=42) +def migration42(tdset): + """ + Adds column to register which table columns are triggered in webhooks. + """ + return tdset.apply_doc_actions([ + add_column('_grist_Triggers', 'watchedColRefList', 'RefList:_grist_Tables_column'), + add_column('_grist_Triggers', 'options', 'Text'), + ]) diff --git a/sandbox/grist/schema.py b/sandbox/grist/schema.py index 4f0cef3c..413e0cfc 100644 --- a/sandbox/grist/schema.py +++ b/sandbox/grist/schema.py @@ -15,7 +15,7 @@ import six import actions -SCHEMA_VERSION = 41 +SCHEMA_VERSION = 42 def make_column(col_id, col_type, formula='', isFormula=False): return { @@ -261,6 +261,8 @@ def schema_create_actions(): make_column("label", "Text"), make_column("memo", "Text"), make_column("enabled", "Bool"), + make_column("watchedColRefList", "RefList:_grist_Tables_column"), + make_column("options", "Text"), ]), # All of the ACL rules. diff --git a/static/locales/de.client.json b/static/locales/de.client.json index 99f4dfe0..eb08d570 100644 --- a/static/locales/de.client.json +++ b/static/locales/de.client.json @@ -233,7 +233,8 @@ "Compare to Previous": "Mit vorherigem vergleichen", "Open Snapshot": "Schnappschuss öffnen", "Snapshots": "Schnappschüsse", - "Snapshots are unavailable.": "Schnappschüsse sind nicht verfügbar." + "Snapshots are unavailable.": "Schnappschüsse sind nicht verfügbar.", + "Only owners have access to snapshots for documents with access rules.": "Nur Eigentümer haben Zugriff auf Snapshots für Dokumente mit Zugriffsregeln." }, "DocMenu": { "(The organization needs a paid plan)": "(Die Organisation benötigt einen bezahlten Plan)", @@ -700,7 +701,9 @@ "Default field value": "Standard-Feldwert", "Field title": "Feldtitel", "Hidden field": "Verborgenes Feld", - "Submit button label": "Beschriftung der Schaltfläche Senden" + "Submit button label": "Beschriftung der Schaltfläche Senden", + "No field selected": "Kein Feld ausgewählt", + "Select a field in the form widget to configure.": "Wählen Sie ein Feld im Formular Widget aus, um es zu konfigurieren." }, "RowContextMenu": { "Copy anchor link": "Ankerlink kopieren", @@ -712,7 +715,8 @@ "Insert row below": "Zeile unten einfügen", "Duplicate rows_one": "Zeile duplizieren", "Duplicate rows_other": "Zeilen duplizieren", - "View as card": "Ansicht als Karte" + "View as card": "Ansicht als Karte", + "Use as table headers": "Verwendung als Tabellenüberschriften" }, "SelectionSummary": { "Copied to clipboard": "In die Zwischenablage kopiert" @@ -738,7 +742,12 @@ "Unsaved": "Ungespeichert", "Work on a Copy": "Arbeiten an einer Kopie", "Share": "Teilen", - "Download...": "Herunterladen..." + "Download...": "Herunterladen...", + "Export as...": "Exportieren als...", + "Microsoft Excel (.xlsx)": "Microsoft Excel (.xlsx)", + "Tab Separated Values (.tsv)": "Tabulatorgetrennte Werte (.tsv)", + "Comma Separated Values (.csv)": "Kommagetrennte Werte (.csv)", + "DOO Separated Values (.dsv)": "DOO-getrennte Werte (.dsv)" }, "SiteSwitcher": { "Create new team site": "Neue Teamseite erstellen", @@ -1279,7 +1288,8 @@ "Help Center": "Hilfe-Center", "Opt in to Telemetry": "Melden Sie sich für Telemetrie an", "Opted In": "Angemeldet", - "Support Grist page": "Support Grist-Seite" + "Support Grist page": "Support Grist-Seite", + "Admin Panel": "Verwaltungsbereich" }, "SupportGristPage": { "GitHub Sponsors page": "GitHub-Sponsorenseite", @@ -1297,7 +1307,8 @@ "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." + "You have opted out of telemetry.": "Sie haben sich von der Telemetrie abgemeldet.", + "Sponsor": "Sponsor" }, "buildViewSectionDom": { "No row selected in {{title}}": "Keine Zeile in {{title}} ausgewählt", @@ -1381,7 +1392,21 @@ "Publish your form?": "Ihr Formular veröffentlichen?", "Unpublish": "Unveröffentlichen", "Unpublish your form?": "Ihr Formular unveröffentlichen?", - "Publish": "Veröffentlichen" + "Publish": "Veröffentlichen", + "Preview": "Vorschau", + "Reset": "Zurücksetzen", + "Share": "Teilen", + "Are you sure you want to reset your form?": "Sind Sie sicher, dass Sie Ihr Formular zurücksetzen möchten?", + "Reset form": "Formular zurücksetzen", + "Save your document to publish this form.": "Speichern Sie Ihr Dokument, um dieses Formular zu veröffentlichen.", + "Anyone with the link below can see the empty form and submit a response.": "Jeder, der den unten stehenden Link anklickt, kann das leere Formular sehen und eine Antwort einreichen.", + "Code copied to clipboard": "Code in die Zwischenablage kopiert", + "Copy code": "Code kopieren", + "Embed this form": "Dieses Formular einbetten", + "Copy link": "Link kopieren", + "Link copied to clipboard": "Link in die Zwischenablage kopiert", + "Share this form": "Dieses Formular teilen", + "View": "Ansicht" }, "Editor": { "Delete": "Löschen" @@ -1451,5 +1476,29 @@ "This week": "Diese Woche", "This year": "Dieses Jahr", "Today": "Heute" + }, + "MappedFieldsConfig": { + "Clear": "Löschen", + "Map fields": "Felder zuordnen", + "Mapped": "Zugeordnet", + "Unmap fields": "Felder freigeben", + "Unmapped": "Nicht zugeordnet", + "Select All": "Alle auswählen" + }, + "AdminPanel": { + "Admin Panel": "Verwaltungsbereich", + "Current": "Aktuell", + "Support Grist": "Unterstützen Sie Grist", + "Telemetry": "Telemetrie", + "Current version of Grist": "Aktuelle Version von Grist", + "Help us make Grist better": "Helfen Sie uns, Grist besser zu machen", + "Home": "Home", + "Sponsor": "Sponsor", + "Support Grist Labs on GitHub": "Unterstützen Sie Grist Labs auf GitHub", + "Version": "Version" + }, + "Section": { + "Insert section above": "Abschnitt oben einfügen", + "Insert section below": "Abschnitt unten einfügen" } } diff --git a/static/locales/en.client.json b/static/locales/en.client.json index 82088c48..e3570a0d 100644 --- a/static/locales/en.client.json +++ b/static/locales/en.client.json @@ -225,7 +225,8 @@ "Compare to Previous": "Compare to Previous", "Open Snapshot": "Open Snapshot", "Snapshots": "Snapshots", - "Snapshots are unavailable.": "Snapshots are unavailable." + "Snapshots are unavailable.": "Snapshots are unavailable.", + "Only owners have access to snapshots for documents with access rules.": "Only owners have access to snapshots for documents with access rules." }, "DocMenu": { "(The organization needs a paid plan)": "(The organization needs a paid plan)", @@ -480,7 +481,8 @@ "Welcome to {{- orgName}}": "Welcome to {{- orgName}}", "Sign in": "Sign in", "To use Grist, please either sign up or sign in.": "To use Grist, please either sign up or sign in.", - "Visit our {{link}} to learn more about Grist.": "Visit our {{link}} to learn more about Grist." + "Visit our {{link}} to learn more about Grist.": "Visit our {{link}} to learn more about Grist.", + "Learn more in our {{helpCenterLink}}, or find an expert via our {{sproutsProgram}}.": "Learn more in our {{helpCenterLink}}, or find an expert via our {{sproutsProgram}}." }, "HomeLeftPane": { "Access Details": "Access Details", @@ -1168,7 +1170,21 @@ }, "WebhookPage": { "Clear Queue": "Clear Queue", - "Webhook Settings": "Webhook Settings" + "Webhook Settings": "Webhook Settings", + "Cleared webhook queue.": "Cleared webhook queue.", + "Columns to check when update (separated by ;)": "Columns to check when update (separated by ;)", + "Enabled": "Enabled", + "Event Types": "Event Types", + "Memo": "Memo", + "Name": "Name", + "Ready Column": "Ready Column", + "Removed webhook.": "Removed webhook.", + "Sorry, not all fields can be edited.": "Sorry, not all fields can be edited.", + "Status": "Status", + "URL": "URL", + "Webhook Id": "Webhook Id", + "Table": "Table", + "Filter for changes in these columns (semicolon-separated ids)": "Filter for changes in these columns (semicolon-separated ids)" }, "FormulaAssistant": { "Ask the bot.": "Ask the bot.", @@ -1278,7 +1294,9 @@ "Opt in to Telemetry": "Opt in to Telemetry", "Opted In": "Opted In", "Support Grist": "Support Grist", - "Support Grist page": "Support Grist page" + "Support Grist page": "Support Grist page", + "Thank you! Your trust and support is greatly appreciated. Opt out any time from the {{link}} in the user menu.": "Thank you! Your trust and support is greatly appreciated. Opt out any time from the {{link}} in the user menu.", + "Admin Panel": "Admin Panel" }, "SupportGristPage": { "GitHub": "GitHub", @@ -1296,7 +1314,8 @@ "We only collect usage statistics, as detailed in our {{link}}, never document contents.": "We only collect usage statistics, as detailed in our {{link}}, never document contents.", "You can opt out of telemetry at any time from this page.": "You can opt out of telemetry at any time from this page.", "You have opted in to telemetry. Thank you!": "You have opted in to telemetry. Thank you!", - "You have opted out of telemetry.": "You have opted out of telemetry." + "You have opted out of telemetry.": "You have opted out of telemetry.", + "Sponsor": "Sponsor" }, "buildViewSectionDom": { "No data": "No data", @@ -1421,5 +1440,31 @@ "Section": { "Insert section above": "Insert section above", "Insert section below": "Insert section below" + }, + "CreateTeamModal": { + "Cancel": "Cancel", + "Choose a name and url for your team site": "Choose a name and url for your team site", + "Create site": "Create site", + "Domain name is invalid": "Domain name is invalid", + "Domain name is required": "Domain name is required", + "Go to your site": "Go to your site", + "Team name": "Team name", + "Team name is required": "Team name is required", + "Team site created": "Team site created", + "Team url": "Team url", + "Work as a Team": "Work as a Team", + "Billing is not supported in grist-core": "Billing is not supported in grist-core" + }, + "AdminPanel": { + "Admin Panel": "Admin Panel", + "Current": "Current", + "Current version of Grist": "Current version of Grist", + "Help us make Grist better": "Help us make Grist better", + "Home": "Home", + "Sponsor": "Sponsor", + "Support Grist": "Support Grist", + "Support Grist Labs on GitHub": "Support Grist Labs on GitHub", + "Telemetry": "Telemetry", + "Version": "Version" } } diff --git a/static/locales/es.client.json b/static/locales/es.client.json index 19a5d475..38da408c 100644 --- a/static/locales/es.client.json +++ b/static/locales/es.client.json @@ -155,7 +155,7 @@ "Others": "Otros", "Search": "Buscar", "Search values": "Buscar valores", - "Start": "Inicio", + "Start": "Iniciar", "Filter by Range": "Filtrar por rango" }, "CustomSectionConfig": { @@ -187,7 +187,8 @@ "Compare to Previous": "Comparar con el anterior", "Open Snapshot": "Abrir instantánea", "Snapshots": "Instantáneas", - "Snapshots are unavailable.": "Las instantáneas no están disponibles." + "Snapshots are unavailable.": "Las instantáneas no están disponibles.", + "Only owners have access to snapshots for documents with access rules.": "Solo los dueños tienen acceso a las instantáneas de los documentos con unas reglas de acceso." }, "DocMenu": { "(The organization needs a paid plan)": "(La organización necesita un plan de pago)", @@ -1277,7 +1278,8 @@ "Opt in to Telemetry": "Participar en Telemetría", "Support Grist page": "Página de soporte de Grist", "Close": "Cerrar", - "Contribute": "Contribuir" + "Contribute": "Contribuir", + "Admin Panel": "Panel de control" }, "SupportGristPage": { "GitHub": "GitHub", @@ -1295,7 +1297,8 @@ "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" + "Home": "Inicio", + "Sponsor": "Patrocinador" }, "buildViewSectionDom": { "No data": "Sin datos", @@ -1475,5 +1478,17 @@ "Section": { "Insert section above": "Insertar la sección anterior", "Insert section below": "Insertar la sección siguiente" + }, + "AdminPanel": { + "Current": "Actual", + "Help us make Grist better": "Ayúdanos a mejorar Grist", + "Home": "Inicio", + "Sponsor": "Patrocinador", + "Support Grist": "Soporte Grist", + "Telemetry": "Telemetría", + "Version": "Versión", + "Current version of Grist": "Versión actual de Grist", + "Admin Panel": "Panel de control", + "Support Grist Labs on GitHub": "Apoya a Grist Labs en GitHub" } } diff --git a/static/locales/fr.client.json b/static/locales/fr.client.json index 74135d33..1c37d97b 100644 --- a/static/locales/fr.client.json +++ b/static/locales/fr.client.json @@ -221,7 +221,8 @@ "Compare to Current": "Comparer au document en cours", "Compare to Previous": "Comparer au précédent", "Beta": "Bêta", - "Snapshots are unavailable.": "Les sauvegardes ne sont pas disponibles." + "Snapshots are unavailable.": "Les sauvegardes ne sont pas disponibles.", + "Only owners have access to snapshots for documents with access rules.": "Seuls les propriétaires ont accès aux instantanés des documents soumis à des règles d'accès." }, "DocMenu": { "Other Sites": "Autres espaces", @@ -652,7 +653,9 @@ "Enter redirect URL": "Saisir l'URL de redirection", "Reset form": "Réinitialiser le formulaire", "Submit another response": "Soumettre une autre réponse", - "Required field": "Champ obligatoire" + "Required field": "Champ obligatoire", + "No field selected": "Aucun champ sélectionné", + "Select a field in the form widget to configure.": "Sélectionnez un champ du formulaire à configurer." }, "RowContextMenu": { "Insert row": "Insérer une ligne", @@ -662,7 +665,8 @@ "Duplicate rows_other": "Dupliquer les lignes", "Delete": "Supprimer", "Copy anchor link": "Copier l'ancre", - "View as card": "Voir en carte" + "View as card": "Voir en carte", + "Use as table headers": "Utiliser en tant qu'en-têtes de table" }, "SelectionSummary": { "Copied to clipboard": "Copié dans le presse-papier" @@ -688,7 +692,9 @@ "Export XLSX": "Exporter en XLSX", "Send to Google Drive": "Envoyer vers Google Drive", "Share": "Partager", - "Download...": "Télécharger..." + "Download...": "Télécharger...", + "Microsoft Excel (.xlsx)": "Microsoft Excel (.xlsx)", + "Export as...": "Exporter en tant que..." }, "SiteSwitcher": { "Switch Sites": "Changer d’espace", @@ -1209,7 +1215,8 @@ "Support Grist": "Support Grist", "Opt in to Telemetry": "S'inscrire à l'envoi de données de télémétrie", "Opted In": "Accepté", - "Support Grist page": "Soutenir Grist" + "Support Grist page": "Soutenir Grist", + "Admin Panel": "Panneau d'administration" }, "GridView": { "Click to insert": "Cliquer pour insérer" @@ -1218,8 +1225,8 @@ "GitHub": "GitHub", "Help Center": "Centre d'aide", "Home": "Accueil", - "Sponsor Grist Labs on GitHub": "Sponsoriser Grist Labs sur GitHub", - "GitHub Sponsors page": "Page de sponsors GitHub", + "Sponsor Grist Labs on GitHub": "Parrainer Grist Labs sur GitHub", + "GitHub Sponsors page": "Page de parrainage GitHub", "Manage Sponsorship": "Gérer le parrainage", "Opt in to Telemetry": "S'inscrire à l'envoi de données de télémétrie", "Opt out of Telemetry": "Se désinscrire de l'envoi de données de télémétrie", @@ -1230,7 +1237,8 @@ "You have opted out of telemetry.": "Vous avez choisi de ne pas envoyer de données de télémétrie.", "We only collect usage statistics, as detailed in our {{link}}, never document contents.": "Nous ne collectons que des statistiques d'usage, comme détaillé dans notre {{link}}, jamais le contenu des documents.", "You can opt out of telemetry at any time from this page.": "Vous pouvez vous désinscrire de la télémétrie à tout moment depuis cette page.", - "You have opted in to telemetry. Thank you!": "Merci de vous être inscrit à la télémétrie !" + "You have opted in to telemetry. Thank you!": "Merci de vous être inscrit à la télémétrie !", + "Sponsor": "Parrainage" }, "buildViewSectionDom": { "No data": "Aucune donnée", @@ -1323,7 +1331,21 @@ "Publish": "Publier", "Publish your form?": "Publier votre formulaire ?", "Unpublish": "Dépublier", - "Unpublish your form?": "Dépublier votre formulaire ?" + "Unpublish your form?": "Dépublier votre formulaire ?", + "Are you sure you want to reset your form?": "Êtes-vous sûr de vouloir réinitialiser votre formulaire ?", + "Anyone with the link below can see the empty form and submit a response.": "Toute personne ayant accès au lien ci-dessous peut voir le formulaire vide et soumettre une réponse.", + "Code copied to clipboard": "Code copié dans le presse-papiers", + "Copy code": "Copier le code", + "View": "Vue", + "Copy link": "Copier le lien", + "Embed this form": "Intégrer ce formulaire", + "Link copied to clipboard": "Lien copié dans le presse-papiers", + "Preview": "Aperçu", + "Reset form": "Réinitialiser le formulaire", + "Save your document to publish this form.": "Enregistrez votre document pour publier ce formulaire.", + "Share this form": "Partager ce formulaire", + "Reset": "Réinitialiser", + "Share": "Partager" }, "HiddenQuestionConfig": { "Hidden fields": "Champs cachés" @@ -1389,5 +1411,29 @@ "This week": "Cette semaine", "This year": "Cette année", "Today": "Aujourd'hui" + }, + "MappedFieldsConfig": { + "Mapped": "Utilisé", + "Select All": "Sélectionner tout", + "Unmap fields": "Champs non utilisés", + "Map fields": "Champs utilisés", + "Clear": "Effacer", + "Unmapped": "Non utilisé" + }, + "Section": { + "Insert section above": "Ajouter une section ci-dessus", + "Insert section below": "Ajouter une section ci-dessous" + }, + "AdminPanel": { + "Current": "Actuelle", + "Current version of Grist": "Version actuelle de Grist", + "Help us make Grist better": "Aidez-nous à améliorer Grist", + "Home": "Accueil", + "Telemetry": "Télémétrie", + "Support Grist Labs on GitHub": "Soutenir Grist Labs sur GitHub", + "Admin Panel": "Panneau d'administration", + "Sponsor": "Parrainage", + "Support Grist": "Soutenir Grist", + "Version": "Version" } } diff --git a/static/locales/pt.client.json b/static/locales/pt.client.json index be012704..73cbd6ac 100644 --- a/static/locales/pt.client.json +++ b/static/locales/pt.client.json @@ -63,7 +63,8 @@ "Compare to Current": "Comparar ao atual", "Compare to Previous": "Comparar ao anterior", "Open Snapshot": "Abrir Instantâneo", - "Snapshots are unavailable.": "Os instantâneos não estão disponíveis." + "Snapshots are unavailable.": "Os instantâneos não estão disponíveis.", + "Only owners have access to snapshots for documents with access rules.": "Apenas os proprietários têm acesso a instantâneos para documentos com regras de acesso." }, "DocMenu": { "By Date Modified": "Por Data de Modificação", @@ -181,7 +182,9 @@ "Enter document name": "Digite o nome do documento", "Remove all data but keep the structure to use as a template": "Remova todos os dados, mas mantenha a estrutura para usar como um modelo", "Remove document history (can significantly reduce file size)": "Remova o histórico do documento (pode reduzir significativamente o tamanho do ficheiro)", - "Download full document and history": "Descarregue documento completo e histórico" + "Download full document and history": "Descarregue documento completo e histórico", + "Download": "Descarregar", + "Download document": "Descarregar documento" }, "Pages": { "Delete": "Apagar", @@ -219,7 +222,27 @@ "WIDGET TITLE": "TÍTULO DO WIDGET", "Widget": "Widget", "You do not have edit access to this document": "Não tem permissão de edição desse documento", - "Add referenced columns": "Adicionar colunas referenciadas" + "Add referenced columns": "Adicionar colunas referenciadas", + "Configuration": "Configuração", + "Default field value": "Valor padrão do campo", + "Display button": "Botão de exibição", + "Enter text": "Digite texto", + "Field title": "Título do campo", + "Layout": "Leiaute", + "Submission": "Envio", + "Submit another response": "Enviar outra resposta", + "Submit button label": "Etiqueta do botão de envio", + "Success text": "Texto de sucesso", + "Table column name": "Nome da coluna da tabela", + "Enter redirect URL": "Insira URL de redirecionamento", + "Reset form": "Restaurar formulário", + "Hidden field": "Campo escondido", + "Redirect automatically after submission": "Redirecionar automaticamente após o envio", + "No field selected": "Nenhum campo selecionado", + "Redirection": "Redirecionamento", + "Select a field in the form widget to configure.": "Selecione um campo no widget do formulário para configurar.", + "Field rules": "Regras de campo", + "Required field": "Campo necessário" }, "ShareMenu": { "Return to {{termToUse}}": "Retornar ao {{termToUse}}", @@ -242,7 +265,12 @@ "Original": "Original", "Replace {{termToUse}}...": "Substituir {{termToUse}}…", "Share": "Partilhar", - "Download...": "Descarregar..." + "Download...": "Descarregar...", + "Comma Separated Values (.csv)": "Valores separados por vírgula (.csv)", + "DOO Separated Values (.dsv)": "Valores separados por DOO (.dsv)", + "Export as...": "Exportar como...", + "Microsoft Excel (.xlsx)": "Microsoft Excel (.xlsx)", + "Tab Separated Values (.tsv)": "Valores separados por tabulação (.tsv)" }, "SiteSwitcher": { "Create new team site": "Criar site de equipa", @@ -318,7 +346,8 @@ "When adding table rules, automatically add a rule to grant OWNER full access.": "Ao adicionar regras de tabela, adicione automaticamente uma regra para conceder ao PROPRIETÁRIO acesso total.", "Permission to edit document structure": "Permissão para editar a estrutura do documento", "This default should be changed if editors' access is to be limited. ": "Esse padrão deve ser alterado se o acesso dos editores for limitado. ", - "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.": "Permita que os editores editem a estrutura (por exemplo, modifiquem e excluam tabelas, colunas, layouts) e escrevam fórmulas, que dão acesso a todos os dados, independentemente das restrições de leitura." + "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.": "Permita que os editores editem a estrutura (por exemplo, modifiquem e excluam tabelas, colunas, layouts) e escrevam fórmulas, que dão acesso a todos os dados, independentemente das restrições de leitura.", + "Add Table-wide Rule": "Adicionar regra para toda a tabela" }, "ChartView": { "Toggle chart aggregation": "Alternar a agregação de gráficos", @@ -390,7 +419,8 @@ "Insert row": "Inserir linha", "Insert row above": "Inserir linha acima", "Insert row below": "Inserir linha abaixo", - "View as card": "Ver como cartão" + "View as card": "Ver como cartão", + "Use as table headers": "Usar como cabeçalhos de tabela" }, "SelectionSummary": { "Copied to clipboard": "Copiado para a área de transferência" @@ -426,7 +456,8 @@ "TOOLS": "FERRAMENTAS", "Tour of this Document": "Tour desse Documento", "Validate Data": "Validar dados", - "Settings": "Configurações" + "Settings": "Configurações", + "API Console": "Consola API" }, "TopBar": { "Manage Team": "Gerir Equipa" @@ -574,7 +605,19 @@ "Adding UUID column": "A adicionar coluna UUID", "Adding duplicates column": "Adicionar coluna duplicatas", "Detect duplicates in...": "Detetar duplicados em...", - "Last updated at": "Última atualização em" + "Last updated at": "Última atualização em", + "Any": "Qualquer", + "Numeric": "Numérico", + "Integer": "Inteiro", + "Toggle": "Alternar", + "Choice": "Opção", + "DateTime": "DataHora", + "Choice List": "Lista de opções", + "Text": "Texto", + "Date": "Data", + "Reference": "Referência", + "Reference List": "Lista de referências", + "Attachment": "Anexo" }, "HomeIntro": { "Any documents created in this site will appear here.": "Qualquer documento criado neste site aparecerá aqui.", @@ -655,7 +698,11 @@ "Lookups return data from related tables.": "As pesquisas retornam dados de tabelas relacionadas.", "You can choose from widgets available to you in the dropdown, or embed your own by providing its full URL.": "Pode escolher entre os widgets disponíveis no menu suspenso ou incorporar o seu próprio widget fornecendo o URL completo.", "Can't find the right columns? Click 'Change Widget' to select the table with events data.": "Não consegue encontrar as colunas certas? Clique em \"Change Widget\" (Alterar widget) para selecionar a tabela com os dados dos eventos.", - "Use reference columns to relate data in different tables.": "Use colunas de referência para relacionar dados em diferentes tabelas." + "Use reference columns to relate data in different tables.": "Use colunas de referência para relacionar dados em diferentes tabelas.", + "Forms are here!": "Os formulários chegaram!", + "Learn more": "Saiba mais", + "Build simple forms right in Grist and share in a click with our new widget. {{learnMoreButton}}": "Crie formulários simples diretamente no Grist e partilhe-os com um clique com o nosso novo widget. {{learnMoreButton}}", + "These rules are applied after all column rules have been processed, if applicable.": "Estas regras são aplicadas após todas as regras da coluna terem sido processadas, se aplicável." }, "WelcomeQuestions": { "IT & Technology": "TI e Tecnologia", @@ -713,7 +760,11 @@ "You do not have access to this organization's documents.": "Não tem acesso aos documentos desta organização.", "Account deleted{{suffix}}": "Conta excluída{{suffix}}", "Your account has been deleted.": "A sua conta foi apagada.", - "Sign up": "Cadastre-se" + "Sign up": "Cadastre-se", + "An unknown error occurred.": "Ocorreu um erro desconhecido.", + "Build your own form": "Construa o seu próprio formulário", + "Form not found": "Formulário não encontrado", + "Powered by": "Desenvolvido por" }, "CellStyle": { "Cell Style": "Estilo de célula", @@ -930,7 +981,8 @@ "Document ID copied to clipboard": "ID do documento copiado para a área de transferência", "Ok": "OK", "Manage Webhooks": "Gerir ganchos web", - "Webhooks": "Ganchos Web" + "Webhooks": "Ganchos Web", + "API Console": "Consola API" }, "DocumentUsage": { "Attachments Size": "Tamanho dos Anexos", @@ -1019,7 +1071,8 @@ "Show raw data": "Mostrar dados primários", "Widget options": "Opções do Widget", "Add to page": "Adicionar à página", - "Collapse widget": "Colapsar widget" + "Collapse widget": "Colapsar widget", + "Create a form": "Criar um formulário" }, "ViewSectionMenu": { "(customized)": "(personalizado)", @@ -1063,7 +1116,17 @@ "modals": { "Cancel": "Cancelar", "Ok": "OK", - "Save": "Gravar" + "Save": "Gravar", + "Are you sure you want to delete this record?": "Tem certeza de que deseja apagar este registo?", + "Don't show tips": "Não mostrar dicas", + "Undo to restore": "Desfazer para restaurar", + "Don't show again.": "Não mostrar novamente.", + "Don't ask again.": "Não perguntar novamente.", + "Are you sure you want to delete these records?": "Tem a certeza de que deseja apagar esses registos?", + "Delete": "Eliminar", + "Dismiss": "Descartar", + "Got it": "Percebido", + "Don't show again": "Não mostrar novamente" }, "pages": { "Duplicate Page": "Duplicar a Página", @@ -1216,7 +1279,8 @@ "Opt in to Telemetry": "Aceitar a Telemetria", "Opted In": "Optou por participar", "Support Grist": "Suporte Grist", - "Support Grist page": "Página de Suporte Grist" + "Support Grist page": "Página de Suporte Grist", + "Admin Panel": "Painel do administrador" }, "SupportGristPage": { "GitHub": "GitHub", @@ -1234,7 +1298,8 @@ "You have opted out of telemetry.": "Decidiu em não participar da telemetria.", "Support Grist": "Suporte Grist", "Telemetry": "Telemetria", - "We only collect usage statistics, as detailed in our {{link}}, never document contents.": "Coletamos apenas estatísticas de uso, conforme detalhado no nosso {{link}}, nunca o conteúdo dos documentos." + "We only collect usage statistics, as detailed in our {{link}}, never document contents.": "Coletamos apenas estatísticas de uso, conforme detalhado no nosso {{link}}, nunca o conteúdo dos documentos.", + "Sponsor": "Patrocinador" }, "buildViewSectionDom": { "No data": "Sem dados", @@ -1255,5 +1320,121 @@ "Delete card": "Apagar cartão", "Copy anchor link": "Copiar ligação de ancoragem", "Insert card": "Inserir cartão" + }, + "Menu": { + "Insert question below": "Inserir questão abaixo", + "Paragraph": "Parágrafo", + "Columns": "Colunas", + "Paste": "Colar", + "Insert question above": "Insira a questão acima", + "Header": "Cabeçalho", + "Copy": "Copiar", + "Cut": "Cortar", + "Building blocks": "Blocos de construção", + "Separator": "Separador", + "Unmapped fields": "Campos não mapeados" + }, + "UnmappedFieldsConfig": { + "Map fields": "Mapear campos", + "Mapped": "Mapeado", + "Clear": "Limpar", + "Select All": "Selecionar Todos", + "Unmapped": "Desmapeado", + "Unmap fields": "Desmapear campos" + }, + "FormView": { + "Are you sure you want to reset your form?": "Tem certeza de que deseja redefinir o formulário?", + "Preview": "Pré-visualização", + "Save your document to publish this form.": "Grave o seu documento para publicar este formulário.", + "Publish your form?": "Publicar o seu formulário?", + "Code copied to clipboard": "Código copiado para a área de transferência", + "Copy code": "Copiar código", + "Copy link": "Copiar ligação", + "Embed this form": "Incorporar este formulário", + "Link copied to clipboard": "Ligação copiada para a área de transferência", + "Reset form": "Redefinir formulário", + "Share": "Partilhar", + "Share this form": "Partilhe este formulário", + "View": "Ver", + "Anyone with the link below can see the empty form and submit a response.": "Qualquer pessoa com a ligação abaixo pode ver o formulário vazio e enviar uma resposta.", + "Reset": "Redefinir", + "Unpublish": "Cancelar publicação", + "Unpublish your form?": "Despublicar o seu formulário?", + "Publish": "Publicar" + }, + "AdminPanel": { + "Home": "Início", + "Sponsor": "Patrocinador", + "Support Grist": "Apoiar o Grist", + "Telemetry": "Telemetria", + "Admin Panel": "Painel do administrador", + "Current": "Atual", + "Current version of Grist": "Versão atual do Grist", + "Help us make Grist better": "Ajude-nos a melhorar o Grist", + "Support Grist Labs on GitHub": "Apoie a Grist Labs no GitHub", + "Version": "Versão" + }, + "Editor": { + "Delete": "Eliminar" + }, + "FormConfig": { + "Field rules": "Regras de campo", + "Required field": "Campo obrigatório" + }, + "CustomView": { + "To use this widget, please map all non-optional columns from the creator panel on the right.": "Para usar este widget, mapeie todas as colunas não opcionais do painel criador à direita.", + "Some required columns aren't mapped": "Algumas colunas obrigatórias não estão mapeadas" + }, + "FormContainer": { + "Build your own form": "Crie o seu próprio formulário", + "Powered by": "Desenvolvido por" + }, + "FormModel": { + "Oops! The form you're looking for doesn't exist.": "Epá! O formulário que procura não existe.", + "Oops! This form is no longer published.": "Ops! Este formulário não está mais publicado.", + "There was a problem loading the form.": "Houve um problema ao carregar o formulário.", + "You don't have access to this form.": "Não tem acesso a este formulário." + }, + "FormSuccessPage": { + "Thank you! Your response has been recorded.": "Obrigado! A sua resposta foi registada.", + "Form Submitted": "Formulário enviado" + }, + "DateRangeOptions": { + "Last 30 days": "Últimos 30 dias", + "This month": "Este mês", + "This week": "Esta semana", + "Last 7 days": "Últimos 7 dias", + "Last Week": "Semana passada", + "Next 7 days": "Próximo 7 dias", + "This year": "Este ano", + "Today": "Hoje" + }, + "FormErrorPage": { + "Error": "Erro" + }, + "FormPage": { + "There was an error submitting your form. Please try again.": "Houve um erro ao enviar o seu formulário. Por favor, tente novamente." + }, + "MappedFieldsConfig": { + "Clear": "Limpar", + "Map fields": "Mapear campos", + "Mapped": "Mapeado", + "Select All": "Selecionar tudo", + "Unmapped": "Desmapeado", + "Unmap fields": "Desmapear campos" + }, + "Section": { + "Insert section above": "Inserir secção acima", + "Insert section below": "Inserir secção abaixo" + }, + "WelcomeCoachingCall": { + "free coaching call": "chamada gratuita de treinamento", + "Schedule Call": "Agendar chamada", + "Maybe Later": "Talvez mais tarde", + "On the call, we'll take the time to understand your needs and tailor the call to you. We can show you the Grist basics, or start working with your data right away to build the dashboards you need.": "Na chamada, vamos ter tempo para perceber as suas necessidades e adaptar a chamada para si. Podemos mostrar-lhe os princípios básicos do Grist ou começar a trabalhar com os seus dados imediatamente para construir os painéis que precisa.", + "Schedule your {{freeCoachingCall}} with a member of our team.": "Programe o seu {{freeCoachingCall}} com um membro da nossa equipa." + }, + "HiddenQuestionConfig": { + "Hidden fields": "Campos ocultos" } } diff --git a/static/locales/pt_BR.client.json b/static/locales/pt_BR.client.json index 9ffa5255..db464e16 100644 --- a/static/locales/pt_BR.client.json +++ b/static/locales/pt_BR.client.json @@ -233,7 +233,8 @@ "Compare to Previous": "Comparar ao anterior", "Open Snapshot": "Abrir Instantâneo", "Snapshots": "Instantâneos", - "Snapshots are unavailable.": "Os instantâneos não estão disponíveis." + "Snapshots are unavailable.": "Os instantâneos não estão disponíveis.", + "Only owners have access to snapshots for documents with access rules.": "Apenas os proprietários têm acesso a instantâneos para documentos com regras de acesso." }, "DocMenu": { "(The organization needs a paid plan)": "(A organização precisa de um plano pago)", @@ -700,7 +701,9 @@ "Redirect automatically after submission": "Redirecionar automaticamente após o envio", "Configuration": "Configuração", "Success text": "Texto de sucesso", - "Layout": "Leiaute" + "Layout": "Leiaute", + "No field selected": "Nenhum campo selecionado", + "Select a field in the form widget to configure.": "Selecione um campo no widget do formulário para configurar." }, "RowContextMenu": { "Copy anchor link": "Copiar o link de ancoragem", @@ -712,7 +715,8 @@ "Insert row below": "Inserir linha abaixo", "Duplicate rows_one": "Duplicar linha", "Duplicate rows_other": "Duplicar linhas", - "View as card": "Ver como cartão" + "View as card": "Ver como cartão", + "Use as table headers": "Usar como cabeçalhos de tabela" }, "SelectionSummary": { "Copied to clipboard": "Copiado para a área de transferência" @@ -738,7 +742,12 @@ "Unsaved": "Não Salvo", "Work on a Copy": "Trabalho em uma cópia", "Share": "Compartilhar", - "Download...": "Baixar..." + "Download...": "Baixar...", + "Tab Separated Values (.tsv)": "Valores separados por tabulação (.tsv)", + "Export as...": "Exportar como...", + "Microsoft Excel (.xlsx)": "Microsoft Excel (.xlsx)", + "Comma Separated Values (.csv)": "Valores separados por vírgula (.csv)", + "DOO Separated Values (.dsv)": "Valores separados por DOO (.dsv)" }, "SiteSwitcher": { "Create new team site": "Criar novo site de equipe", @@ -1279,7 +1288,8 @@ "Support Grist": "Suporte Grist", "Contribute": "Contribuir", "Opted In": "Optou por participar", - "Support Grist page": "Página de Suporte Grist" + "Support Grist page": "Página de Suporte Grist", + "Admin Panel": "Painel do administrador" }, "SupportGristPage": { "GitHub": "GitHub", @@ -1297,7 +1307,8 @@ "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!" + "You have opted in to telemetry. Thank you!": "Você optou pela telemetria. Obrigado!", + "Sponsor": "Patrocinador" }, "buildViewSectionDom": { "No data": "Sem dados", @@ -1381,7 +1392,21 @@ "Publish": "Publicar", "Publish your form?": "Publicar o seu formulário?", "Unpublish your form?": "Despublicar seu formulário?", - "Unpublish": "Cancelar publicação" + "Unpublish": "Cancelar publicação", + "Are you sure you want to reset your form?": "Tem certeza de que deseja redefinir o formulário?", + "Embed this form": "Incorporar este formulário", + "Link copied to clipboard": "Link copiado para a área de transferência", + "Reset form": "Redefinir formulário", + "Share this form": "Compartilhe este formulário", + "View": "Ver", + "Anyone with the link below can see the empty form and submit a response.": "Qualquer pessoa com o link abaixo pode ver o formulário vazio e enviar uma resposta.", + "Copy link": "Copiar link", + "Reset": "Redefinir", + "Save your document to publish this form.": "Salve seu documento para publicar esse formulário.", + "Share": "Compartilhar", + "Code copied to clipboard": "Código copiado para a área de transferência", + "Copy code": "Copiar código", + "Preview": "Pré-visualização" }, "Menu": { "Columns": "Colunas", @@ -1451,5 +1476,29 @@ "This week": "Esta semana", "This year": "Este ano", "Today": "Hoje" + }, + "MappedFieldsConfig": { + "Select All": "Selecionar tudo", + "Unmap fields": "Desmapear campos", + "Unmapped": "Desmapeado", + "Clear": "Limpar", + "Map fields": "Mapear campos", + "Mapped": "Mapeado" + }, + "Section": { + "Insert section above": "Inserir seção acima", + "Insert section below": "Inserir seção abaixo" + }, + "AdminPanel": { + "Current": "Atual", + "Current version of Grist": "Versão atual do Grist", + "Help us make Grist better": "Ajude-nos a melhorar o Grist", + "Support Grist Labs on GitHub": "Apoie a Grist Labs no GitHub", + "Admin Panel": "Painel do administrador", + "Home": "Início", + "Sponsor": "Patrocinador", + "Support Grist": "Apoiar o Grist", + "Telemetry": "Telemetria", + "Version": "Versão" } } diff --git a/static/locales/ru.client.json b/static/locales/ru.client.json index b5d80155..d46da98e 100644 --- a/static/locales/ru.client.json +++ b/static/locales/ru.client.json @@ -358,7 +358,8 @@ "Beta": "Beta", "Compare to Current": "Сравните с текущим", "Snapshots are unavailable.": "Снимки недоступны.", - "Open Snapshot": "Открыть Снимок" + "Open Snapshot": "Открыть Снимок", + "Only owners have access to snapshots for documents with access rules.": "Только владельцы имеют доступ к снимкам документов с правилами доступа." }, "DocMenu": { "By Name": "По имени", @@ -1268,7 +1269,8 @@ "Support Grist": "Поддержка Grist", "Support Grist page": "Страница поддержки Grist", "Contribute": "Участвовать", - "Opted In": "Подключено" + "Opted In": "Подключено", + "Admin Panel": "Панель администратора" }, "SupportGristPage": { "GitHub": "GitHub", @@ -1286,7 +1288,8 @@ "Opt out of Telemetry": "Отказаться от телеметрии", "Sponsor Grist Labs on GitHub": "Спонсор Grist Labs на GitHub", "This instance is opted in to telemetry. Only the site administrator has permission to change this.": "В этом экземпляре включена телеметрия. Только администратор сайта может изменить это.", - "We only collect usage statistics, as detailed in our {{link}}, never document contents.": "Мы собираем только статистику использования, как описано в нашей {{link}}, никогда содержимое документов." + "We only collect usage statistics, as detailed in our {{link}}, never document contents.": "Мы собираем только статистику использования, как описано в нашей {{link}}, никогда содержимое документов.", + "Sponsor": "Спонсор" }, "buildViewSectionDom": { "No data": "Нет данных", @@ -1421,5 +1424,17 @@ "Section": { "Insert section above": "Вставить секцию выше", "Insert section below": "Вставить секцию ниже" + }, + "AdminPanel": { + "Current": "Текущий", + "Current version of Grist": "Текущая версия Grist", + "Help us make Grist better": "Помогите нам сделать Grist лучше", + "Home": "Домой", + "Sponsor": "Спонсор", + "Support Grist": "Поддержать Grist", + "Support Grist Labs on GitHub": "Поддержите Grist Labs на GitHub", + "Version": "Версия", + "Admin Panel": "Панель администратора", + "Telemetry": "Телеметрия" } } diff --git a/static/locales/sl.client.json b/static/locales/sl.client.json index f63f69d2..4fbeb5e5 100644 --- a/static/locales/sl.client.json +++ b/static/locales/sl.client.json @@ -412,7 +412,8 @@ "Compare to Previous": "Primerjava s prejšnjimi", "Snapshots": "Posnetki", "Snapshots are unavailable.": "Posnetki niso na voljo.", - "Open Snapshot": "Odpri posnetek stanja" + "Open Snapshot": "Odpri posnetek stanja", + "Only owners have access to snapshots for documents with access rules.": "Samo lastniki imajo dostop do posnetkov za dokumente s pravili dostopa." }, "ExampleInfo": { "Check out our related tutorial for how to link data, and create high-productivity layouts.": "Oglejte si sorodno navodilo za povezovanje podatkov in ustvarjanje visokoproduktivnih postavitev.", @@ -679,7 +680,8 @@ "Opt in to Telemetry": "Prijava na telemetrijo", "You have opted in to telemetry. Thank you!": "Prijavili ste se za telemetrijo. Zahvaljujemo se vam!", "This instance is opted in to telemetry. Only the site administrator has permission to change this.": "Ta primerek je prijavljen v telemetrijo. To lahko spremeni le skrbnik spletnega mesta.", - "GitHub": "GitHub" + "GitHub": "GitHub", + "Sponsor": "Sponzor" }, "GristTooltips": { "Updates every 5 minutes.": "Posodablja se vsakih 5 minut.", @@ -845,7 +847,8 @@ "Help Center": "Center za pomoč", "Contribute": "Prispevajte", "Support Grist page": "Grist podpora", - "Opted In": "Prijavljeno" + "Opted In": "Prijavljeno", + "Admin Panel": "Skrbniški panel" }, "HomeIntro": { "personal site": "osebna stran", @@ -995,7 +998,21 @@ }, "WebhookPage": { "Clear Queue": "Počisti čakalno vrsto", - "Webhook Settings": "Nastavitve Webhook" + "Webhook Settings": "Nastavitve Webhook", + "Enabled": "Omogočeno", + "Memo": "Beležka", + "Name": "Ime", + "Ready Column": "Pripravljen stolpec", + "Removed webhook.": "Odstranjen webhook.", + "Sorry, not all fields can be edited.": "Žal vseh polj ni mogoče urejati.", + "Status": "Status", + "Table": "Tabela", + "Filter for changes in these columns (semicolon-separated ids)": "Filter za spremembe v teh stolpcih (id-ji, ločeni s podpičjem)", + "Cleared webhook queue.": "Čakalna vrsta webhook je počiščena.", + "URL": "URL", + "Webhook Id": "Webhook ID", + "Columns to check when update (separated by ;)": "Stolpci za preverjanje ob posodobitvi (ločeni z ;)", + "Event Types": "Vrste dogodkov" }, "RecordLayout": { "Updating record layout.": "Posodobitev postavitve zapisa." @@ -1421,5 +1438,17 @@ "Section": { "Insert section above": "Vstavi razdelek zgoraj", "Insert section below": "Vstavite razdelek spodaj" + }, + "AdminPanel": { + "Help us make Grist better": "Pomagaj nam izboljšati Grist", + "Home": "Domov", + "Admin Panel": "Skrbniški panel", + "Current": "Trenutno", + "Current version of Grist": "Trenutna različica Grista", + "Sponsor": "Sponzor", + "Support Grist": "Podpora Gristu", + "Telemetry": "Telemetrija", + "Support Grist Labs on GitHub": "Podpri Grist Labs na GitHubu", + "Version": "Verzija" } } diff --git a/static/locales/vi.client.json b/static/locales/vi.client.json new file mode 100644 index 00000000..ba760f34 --- /dev/null +++ b/static/locales/vi.client.json @@ -0,0 +1,1425 @@ +{ + "ACUserManager": { + "Enter email address": "Nhập địa chỉ Email", + "Invite new member": "Mời thành viên mới", + "We'll email an invite to {{email}}": "Chúng tôi sẽ gửi email lời mời đến {{email}}" + }, + "AccessRules": { + "Add Default Rule": "Thêm quy tác mặc định", + "Add Column Rule": "Thêm quy tắc cho cột", + "Allow everyone to copy the entire document, or view it in full in fiddle mode.\nUseful for examples and templates, but not for sensitive data.": "Cho phép mọi người sao chép toàn bộ tài liệu hoặc xem toàn bộ tài liệu ở chế độ trung gian. Hữu ích cho các ví dụ và mẫu, nhưng không dành cho dữ liệu nhạy cảm.", + "Allow everyone to view Access Rules.": "Cho phép mọi người xem quy tắc truy cập", + "Attribute name": "Tên thuộc tính", + "Attribute to Look Up": "Thuộc tính để tra cứu", + "Checking...": "Đang kiểm tra.…", + "Condition": "Điều kiện", + "Default Rules": "Quy tắc mặc định", + "Delete Table Rules": "Xóa quy tắc bảng", + "Enter Condition": "Nhập điều kiện", + "Everyone": "Tất cả mọi người", + "Everyone Else": "Những người khác", + "Lookup Column": "Cột tra cứu", + "Lookup Table": "Bảng tra cứu", + "Permission to access the document in full when needed": "Cho phép truy cập toàn bộ tài liệu khi cần thiết", + "Permission to view Access Rules": "Quyền xem Quy tắc truy cập", + "Permissions": "Quyền hạn", + "Remove column {{- colId }} from {{- tableId }} rules": "Xóa cột {{- colId }} khỏi quy tắc {{- tableId}}", + "Remove {{- tableId }} rules": "Xóa {{- tableId} } quy tắc", + "Remove {{- name }} user attribute": "Xóa {{- name }} thuộc tính người dùng", + "Reset": "Đặt lại", + "Rules for table ": "Quy tắc cho bảng ", + "Save": "Lưu", + "Saved": "Đã lưu", + "Special Rules": "Quy tắc đặc biệt", + "Type a message...": "Nhập tin nhắn", + "User Attributes": "Thuộc tính người dùng", + "View As": "Xem dưới dạng", + "Seed rules": "Quy tắc hạt giống", + "When adding table rules, automatically add a rule to grant OWNER full access.": "Khi thêm quy tắc bảng, tự động thêm quy tắc để cấp cho CHỦ SỞ HỮU toàn quyền truy cập.", + "This default should be changed if editors' access is to be limited. ": "Mặc định này nên được thay đổi nếu quyền truy cập của biên tập viên bị hạn chế. ", + "Add Table-wide Rule": "Thêm quy tắc toàn bảng", + "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.": "Cho phép người chỉnh sửa chỉnh sửa cấu trúc (ví dụ: sửa đổi và xóa bảng, cột, bố cục) và viết công thức, cho phép truy cập vào tất cả dữ liệu bất kể hạn chế đọc.", + "Add Table Rules": "Thêm quy tắc cho bảng", + "Add User Attributes": "Thêm thuộc tính người dùng", + "Invalid": "Không hợp lệ", + "Permission to edit document structure": "Quyền chỉnh sửa cấu trúc tài liệu" + }, + "AccountPage": { + "API": "API", + "API Key": "API Key", + "Account settings": "Cài đặt tài khoản", + "Allow signing in to this account with Google": "Cho phép đăng nhập vào tài khoản này bằng Google", + "Change Password": "Thay đổi mật khẩu", + "Edit": "Chỉnh sửa", + "Login Method": "Phương thức đăng nhập", + "Name": "Tên", + "Names only allow letters, numbers and certain special characters": "Tên chỉ cho phép chữ cái, số và một số ký tự đặc biệt nhất định", + "Save": "Lưu", + "Theme": "Mục", + "Two-factor authentication": "Xác thực hai lớp", + "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.": "Xác thực hai yếu tố là một lớp bảo mật bổ sung cho tài khoản Grist của bạn được thiết kế để đảm bảo rằng bạn là người duy nhất có thể truy cập vào tài khoản của mình, ngay cả khi ai đó biết mật khẩu của bạn.", + "Language": "Ngôn ngữ", + "Email": "Email", + "Password & Security": "Mật khẩu & Bảo mật" + }, + "AccountWidget": { + "Access Details": "Chi tiết truy cập", + "Accounts": "Tài khoản", + "Add Account": "Thêm tài khoản", + "Document Settings": "Cài đặt tài liệu", + "Manage Team": "Quản lý nhóm", + "Pricing": "Định giá", + "Profile Settings": "Cài đặt hồ sơ", + "Sign Out": "Đăng xuất", + "Sign in": "Đăng nhập", + "Switch Accounts": "Chuyển đổi tài khoản, Sử dụng tài khoản khác", + "Toggle Mobile Mode": "Chuyển đổi chế độ di động", + "Billing Account": "Tài khoản thanh toán", + "Support Grist": "Hỗ trợ Grist", + "Upgrade Plan": "Gói nâng cấp", + "Sign In": "Đăng nhập", + "Sign Up": "Đăng ký", + "Use This Template": "Sử dụng mẫu này", + "Activation": "Kích hoạt" + }, + "ViewAsDropdown": { + "Users from table": "Người dùng từ bảng", + "Example Users": "Ví dụ người dùng", + "View As": "Xem dưới dạng" + }, + "ActionLog": { + "Action Log failed to load": "Nhật ký hành động không tải được", + "Column {{colId}} was subsequently removed in action #{{action.actionNum}}": "Cột {{colld}} đã bị xóa trong quá trình hành động #{{action.actionNum}}", + "Table {{tableId}} was subsequently removed in action #{{actionNum}}": "Bảng {{tableld}} đã bị xóa trong quá trình hành động #{{action.actionNum}}", + "This row was subsequently removed in action {{action.actionNum}}": "Hàng này đã bị xóa trong quá trình hành động {{action.actionNum}}", + "All tables": "Tất cả các bảng" + }, + "AddNewButton": { + "Add New": "Thêm mới, Tạo mới" + }, + "ApiKey": { + "By generating an API key, you will be able to make API calls for your own account.": "Bằng cách tạo khóa API, bạn sẽ có thể thực hiện các cuộc gọi API cho tài khoản của riêng mình", + "Click to show": "Nhấp để hiển thị, Xem thêm", + "Remove API Key": "Gỡ khóa API", + "This API key can be used to access this account anonymously via the API.": "Khóa API này có thể được dùng để truy cập ẩn danh vào tài khoản này thông qua API", + "This API key can be used to access your account via the API. Don’t share your API key with anyone.": "This API key can be used to access your account via the API. Don’t share your API key with anyone.", + "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?": "Bạn đang chuẩn bị xóa một khóa API. Điều này sẽ khiến tất cả các yêu cầu trong tương lai sử dụng khóa API này đều bị từ chối. Bạn có chắc chắn vẫn muốn xóa không?", + "Create": "Tạo", + "Remove": "Gỡ, Xóa" + }, + "App": { + "Memory Error": "Lỗi bộ nhớ", + "Translators: please translate this only when your language is ready to be offered to users": "Người dịch: vui lòng dịch điều này chỉ khi ngôn ngữ của bạn đã sẵn sàng để được cung cấp cho người dùng", + "Description": "Mô tả", + "Key": "Ký hiệu" + }, + "AppHeader": { + "Home Page": "Trang chủ", + "Legacy": "Di sản", + "Personal Site": "Trang wed cá nhân", + "Team Site": "Trang wed nhóm", + "Grist Templates": "Mẫu Grist" + }, + "AppModel": { + "This team site is suspended. Documents can be read, but not modified.": "Trang wed của nhóm này đã bị tạm dừng. Tài liệu có thể được đọc, nhưng không thể sửa" + }, + "CellContextMenu": { + "Clear cell": "Xóa ô", + "Clear values": "Xóa giá trị", + "Copy anchor link": "Sao chép liên kết Anchor", + "Delete {{count}} columns_one": "Xóa cột", + "Delete {{count}} columns_other": "Xóa {{count}} cột", + "Delete {{count}} rows_one": "Xóa hàng", + "Delete {{count}} rows_other": "Xóa {{count}} hàng", + "Duplicate rows_one": "Sao chép dòng, Sao chép hàng", + "Duplicate rows_other": "Các dòng trùng nhau", + "Filter by this value": "Lọc theo giá trị này", + "Insert column to the left": "Thêm một cột ở bên trái", + "Insert column to the right": "Thêm một cột ở bên phải", + "Insert row": "Chèn dòng mới", + "Insert row above": "Chèn hàng ở trên", + "Insert row below": "Chèn hàng ở bên dưới", + "Reset {{count}} columns_one": "Làm mới cột", + "Reset {{count}} columns_other": "Làm mới {{count}} cột", + "Reset {{count}} entire columns_one": "Làm mới toàn bộ các cột", + "Reset {{count}} entire columns_other": "Làm mới toàn bộ {{count}} cột", + "Comment": "Bình luận", + "Copy": "Sao chép", + "Cut": "Cắt", + "Paste": "Dán (Paste)" + }, + "ChartView": { + "Create separate series for each value of the selected column.": "Tạo các chuỗi khác nhau cho mỗi giá trị của cột đã chọn", + "Each Y series is followed by a series for the length of error bars.": "Sau mỗi chuỗi Y là một chuỗi về độ dài của các thanh lỗi.", + "Each Y series is followed by two series, for top and bottom error bars.": "Mỗi chuỗi Y được theo sau mỗi hai chuỗi,dành cho các thanh lỗi trên và dưới", + "Pick a column": "Chọn một cột", + "Toggle chart aggregation": "Chuyển đổi tổng hợp biểu đồ", + "selected new group data columns": "Các cột dữ liệu nhóm mới đã được chọn" + }, + "CodeEditorPanel": { + "Access denied": "Từ chối truy cập", + "Code View is available only when you have full document access.": "Chế độ xem mã chỉ khả dụng khi bạn có toàn quyền truy cập tài liệu." + }, + "ColorSelect": { + "Apply": "Áp dụng", + "Cancel": "Hủy", + "Default cell style": "Kiểu ô mặc định" + }, + "ColumnFilterMenu": { + "All": "Tất cả", + "All Except": "Tất cả ngoại trừ", + "All Shown": "Hiển thị tất cả", + "Filter by Range": "Lọc theo phạm vi", + "Future Values": "Giá trị tương lai", + "No matching values": "Không có giá trị phù hợp", + "None": "Không có", + "Min": "Tối thiểu", + "Max": "Tối đa", + "Start": "Bắt đầu", + "End": "Kết thúc", + "Other Matching": "Đối xứng khác", + "Other Non-Matching": "Khác không phù hợp", + "Other Values": "Các giá trị khác", + "Others": "Khác", + "Search": "Tìm kiếm", + "Search values": "Tìm kiếm giá trị" + }, + "CustomSectionConfig": { + " (optional)": " (không bắt buộc)", + "Add": "Thêm vào", + "Enter Custom URL": "Nhập một URL ngẫu nhiên", + "Full document access": "Toàn quyền truy cập tài liệu", + "Learn more about custom widgets": "Tìm hiểu thêm về tiện ích tùy chỉnh", + "No document access": "Không có quyền truy cập tài liệu", + "Open configuration": "Mở cấu hình", + "Pick a column": "Chọn một cột", + "Pick a {{columnType}} column": "Chọn một cột {{columnType}}", + "Read selected table": "Đọc bảng đã chọn", + "Select Custom Widget": "Chọn tiện ích tùy chỉnh", + "Widget needs to {{read}} the current table.": "Tiện ích cần {{read}} bảng hiện tại.", + "Widget does not require any permissions.": "Tiện ích không yêu cầu bất kỳ quyền nào.", + "Widget needs {{fullAccess}} to this document.": "Tiện ích cần {{fullAccess}} cho tài liệu này.", + "{{wrongTypeCount}} non-{{columnType}} columns are not shown_one": "Cột {{wrongTypeCount}} không phải{{columnType}} đang không được hiển thị", + "{{wrongTypeCount}} non-{{columnType}} columns are not shown_other": "{{wrongTypeCount}} cột không phải{{columnType}} không được hiển thị", + "No {{columnType}} columns in table.": "Không có {{columnType}} cột trong bảng.", + "Clear selection": "Xoá lựa chọn" + }, + "DataTables": { + "Click to copy": "Bấm để sao chép", + "Delete {{formattedTableName}} data, and remove it from all pages?": "Xóa {{formattedTableName}} dữ liệu và xóa nó khỏi tất cả các trang?", + "Duplicate Table": "Bảng Trùng lặp", + "Raw Data Tables": "Bảng Dữ liệu thô", + "Table ID copied to clipboard": "Đã sao chép ID bảng vào bảng nhớ tạm", + "You do not have edit access to this document": "Bạn không có quyền chỉnh sửa tài liệu này", + "Edit Record Card": "Chỉnh sửa bản ghi", + "Record Card": "Thẻ ghi âm", + "Record Card Disabled": "Thẻ ghi âm bị vô hiệu hóa", + "Remove Table": "Xoá bảng", + "Rename Table": "Đổi tên bảng", + "{{action}} Record Card": "{{action}} Thẻ ghi âm" + }, + "DocHistory": { + "Activity": "Hoạt động công việc", + "Beta": "Bản thử nghiệm", + "Compare to Current": "So sánh với dữ liệu hiện tại", + "Compare to Previous": "So sánh với dữ liệu trước đó", + "Open Snapshot": "Mở ảnh chụp nhanh", + "Snapshots": "Ảnh chụp nhanh", + "Snapshots are unavailable.": "Ảnh chụp nhanh không có sẵn" + }, + "DocMenu": { + "(The organization needs a paid plan)": "(Tổ chức cần gói dịch vụ trả phí)", + "Access Details": "Chi tiết truy cập", + "All Documents": "Toàn bộ tài liệu", + "Discover More Templates": "Khám phá thêm các mẫu", + "By Date Modified": "Lọc theo ngày đã sửa đổi", + "By Name": "Theo tên", + "Current workspace": "Không gian làm việc hiện tại", + "Delete Forever": "Xóa vĩnh viễn", + "Delete {{name}}": "Xóa {{name}}?", + "Deleted {{at}}": "Đã xóa {{at}}", + "Document will be moved to Trash.": "Tài liệu sẽ được đưa vào thùng rác", + "Document will be permanently deleted.": "Tài liệu sẽ được xóa vĩnh viễn", + "Documents stay in Trash for 30 days, after which they get deleted permanently.": "Tài liệu nằm trong thùng rác 30 ngày, sau đó sẽ bị xóa vĩnh viễn", + "Edited {{at}}": "Đã sửa {{at}}", + "Examples & Templates": "Các ví dụ và mẫu", + "Featured": "Tính năng hoặc mục nổi bật", + "Examples and Templates": "Ví dụ và mẫu", + "Manage Users": "Quản lí người dùng", + "More Examples and Templates": "Thêm nhiều ví dụ và mẫu khác", + "Move {{name}} to workspace": "Di chuyển {{name}} tới không gian làm việc", + "Other Sites": "Các web khác", + "Permanently Delete \"{{name}}\"?": "Xóa {{name}} vĩnh viễn?", + "Pin Document": "Ghim tài liệu", + "Remove": "Gỡ, Xóa", + "Rename": "Đổi tên", + "Requires edit permissions": "Yêu cầu quyền chỉnh sửa", + "Restore": "Khôi phục", + "This service is not available right now": "Dịch vụ không khả dụng", + "To restore this document, restore the workspace first.": "Để khôi phục tài liệu này, khôi phục không gian làm việc trước", + "Trash": "Thùng rác", + "Trash is empty.": "Thùng rác trống", + "Unpin Document": "Gỡ ghim tài liệu", + "Workspace not found": "Không tìm thấy không gian làm việc", + "You are on your personal site. You also have access to the following sites:": "Bạn đang ở trang cá nhân của bạn. bạn cũng có thể truy cập vào trang:", + "You may delete a workspace forever once it has no documents in it.": "Bạn có thể xóa không gian tài liệu vĩnh viễn khi không còn tài liệu nào trong đó", + "You are on the {{siteName}} site. You also have access to the following sites:": "Bạn đang ở trang{{siteName}}. bạn cũng có thể truy cập vào trang:", + "Delete": "Xóa", + "Move": "Di chuyển", + "Pinned Documents": "Tài liệu đã ghim" + }, + "DocPageModel": { + "Add Empty Table": "Thêm bảng trống", + "Add Page": "Thêm trang", + "Add Widget to Page": "Thêm tiện ích vào trang", + "Document owners can attempt to recover the document. [{{error}}]": "Chủ tài liệu có thể cố khôi phục tài liệu [{{error}}]", + "Enter recovery mode": "Vào chế độ phục hồi", + "Sorry, access to this document has been denied. [{{error}}]": "Xin lỗi, quyền truy cập tài liệu bị từ chối[{{error}}]", + "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}}]": "Bạn có thể thử tải lại tài liệu, hoặc sử dụng chế độ khôi phục. Chế độ khôi phục mở tài liệu để trở nên hoàn toàn truy cập được cho chủ sở hữu và không thể truy cập được cho người khác. Nó cũng vô hiệu hóa các công thức [{{error}}]", + "Error accessing document": "Lỗi truy cập tài liệu", + "Reload": "Tải lại", + "You do not have edit access to this document": "Bạn không có quyền chỉnh sửa tài liệu này" + }, + "DocTour": { + "No valid document tour": "Không có tài liệu hành trình hợp lệ", + "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.": "Không thể tạo hành trình tài liệu từ dữ liệu trong tài liệu này. Đảm bảo rằng có một bảng có tên là GristDocTour với các cột tiêu đề, nội dung, vị trí và địa điểm" + }, + "DocumentSettings": { + "Currency:": "Đơn vị tiền tệ", + "Engine (experimental {{span}} change at own risk):": "Động cơ (thay đổi thử nghiệm{{span}} tự chịu rủi ro)", + "Document Settings": "Cài đặt tài liệu", + "Local currency ({{currency}})": "Nội tệ ({{currency}})", + "Locale:": "Cài đặt vị trí", + "Save": "Lưu", + "Save and Reload": "Lưu và tải lại", + "This document's ID (for API use):": "ID tài liệu này (cho API sử dụng)", + "Time Zone:": "Múi giờ", + "Document ID copied to clipboard": "ID tài liệu được sao chép vào khay nhớ tạm", + "Ok": "Được, chốt", + "Manage Webhooks": "Quản lý Webhooks", + "API Console": "Bảng điều khiển API", + "API": "API", + "Webhooks": "Webhooks" + }, + "DocumentUsage": { + "Contact the site owner to upgrade the plan to raise limits.": "Liên hệ với chủ sở hữu trang web để nâng cấp cho kế hoạch để tăng giới hạn", + "Attachments Size": "Kích thước của tệp đính kèm", + "Data Size": "Kích thước dữ liệu", + "For higher limits, ": "Với các giới hạn cao hơn ", + "Usage": "Công dụng", + "Usage statistics are only available to users with full access to the document data.": "Thống kê sử dụng chỉ có cho người dùng có toàn quyền truy cập tới dữ liệu tài liệu", + "start your 30-day free trial of the Pro plan.": "Bắt đầu dùng thử gói miễn phí Pro trong 30 ngày.", + "Rows": "Nhiều hàng" + }, + "Drafts": { + "Undo discard": "Huỷ hoàn tác", + "Restore last edit": "Khôi phục lại chỉnh sửa lần cuối" + }, + "DuplicateTable": { + "Copy all data in addition to the table structure.": "Sao chép tất cả dữ liệu đến cấu trúc bảng", + "Instead of duplicating tables, it's usually better to segment data using linked views. {{link}}": "Thay vì sao chép các bảng, sẽ tốt hơn nếu phân chia dữ liệu bằng chế độ xem liên kết. {{link}}", + "Only the document default access rules will apply to the copy.": "Chỉ có quyền truy cập tài liệu mặc định mới áp dụng cho bản sao", + "Name for new table": "Tên cho bảng mới" + }, + "ExampleInfo": { + "Afterschool Program": "Chương trình học ngoài giờ", + "Check out our related tutorial for how to link data, and create high-productivity layouts.": "Hãy xem hướng dẫn của chúng tôi liên quan tới cách làm thế nào để liên kết dữ liệu và tạo bố cục nâng cao năng suất", + "Check out our related tutorial to learn how to create summary tables and charts, and to link charts dynamically.": "Hãy xem hướng dẫn của chúng tôi để tìm hiểu cách để tạo bảng và biểu đồ, và liên kết tới biểu đồ một cách linh hoạt", + "Tutorial: Analyze & Visualize": "Hướng dẫn: Phân tích và hình dung", + "Tutorial: Create a CRM": "Hướng dẫn: Tạo CRM cơ bản", + "Tutorial: Manage Business Data": "Hướng dẫn: Quản lý dữ liệu doanh nghiệp", + "Welcome to the Afterschool Program template": "Chào mừng bạn đến với Chương trình học ngoài giờ", + "Welcome to the Investment Research template": "Chào mừng bạn đến với nghiên cứu mẫu đầu tư", + "Welcome to the Lightweight CRM template": "Chào mừng bạn đến với mẫu CRM cơ bản", + "Check out our related tutorial for how to model business data, use formulas, and manage complexity.": "Hãy xem hướng dẫn của chúng tôi về cách lập mô hình dữ liệu kinh doanh, sử dụng công thức, và quản lý độ phức tạp", + "Investment Research": "Tra cứu đầu tư", + "Lightweight CRM": "CRM cơ bản" + }, + "FieldConfig": { + "COLUMN BEHAVIOR": "TẬP TÍNH CỦA CỘT", + "COLUMN LABEL AND ID": "Nhãn cột và ID", + "Clear and make into formula": "Xoá và tạo thành công thức", + "Clear and reset": "Xoá và tải lại", + "Column options are limited in summary tables.": "tuỳ chọn cột bị giới hạn trong các bảng sơ lược", + "Convert column to data": "Chuyển đổi cột thành dữ liệu", + "Convert to trigger formula": "Chuyển thành công thức kích hoạt", + "Data Columns_one": "Dữ liệu cột", + "Data Columns_other": "Dữ liệu cột", + "Empty Columns_one": "Cột trống", + "Empty Columns_other": "Nhiều cột trống", + "Enter formula": "Nhập công thức", + "Formula Columns_one": "Cột công thức", + "Formula Columns_other": "Nhiều cột công thức", + "Make into data column": "Biến thành cột dữ liệu", + "Mixed Behavior": "Hành vi tập tính", + "Set formula": "Đặt công thức", + "Set trigger formula": "Đặt công thức kích hoạt", + "TRIGGER FORMULA": "Kích hoạt công thức", + "DESCRIPTION": "Mô Tả" + }, + "FieldMenus": { + "Using separate settings": "Dùng cài đặt riêng", + "Revert to common settings": "hoàn lại về cài đặt chung", + "Save as common settings": "Lưu lại bởi cài đặt chung", + "Use separate settings": "Sử dụng cài đặt riêng", + "Using common settings": "Sử dụng các cài đặt thường" + }, + "FilterBar": { + "SearchColumns": "Tìm cột", + "Search Columns": "Tìm nhiều cột" + }, + "GridOptions": { + "Grid Options": "Cài đặt kẻ bảng", + "Horizontal Gridlines": "Đường lưới nằm ngang", + "Vertical Gridlines": "Đường lưới dọc", + "Zebra Stripes": "Dải sọc Ngựa vằn" + }, + "GridViewMenus": { + "Add Column": "Thêm cột", + "Add to sort": "Thêm vào danh sách sắp xếp đã có sẵn", + "Clear values": "Xóa giá trị", + "Column Options": "Column Options", + "Convert formula to data": "Chuyển đổi công thức thành dữ liệu", + "Delete {{count}} columns_one": "Xóa cột", + "Delete {{count}} columns_other": "Xóa {{count}} cột", + "Freeze {{count}} columns_one": "Cố định cột này", + "Freeze {{count}} columns_other": "Cố định {{count}} cột", + "Freeze {{count}} more columns_one": "Cố định thêm một cột", + "Freeze {{count}} more columns_other": "Cố định {{count}} thêm cột", + "Hide {{count}} columns_one": "Ẩn cột", + "Hide {{count}} columns_other": "Ẩn {{count}} cột", + "Insert column to the {{to}}": "Chèn thêm cột vào {{to}}", + "More sort options ...": "Các lựa chọn sắp xếp khác", + "Rename column": "Đổi tên cột", + "Reset {{count}} columns_one": "Làm mới cột", + "Reset {{count}} columns_other": "Làm mới {{count}} cột", + "Reset {{count}} entire columns_one": "Làm mới toàn bộ các cột", + "Reset {{count}} entire columns_other": "Làm mới toàn bộ {{count}} cột", + "Show column {{- label}}": "Hiển thị cột {{- label}}", + "Sort": "Sắp xếp", + "Sorted (#{{count}})_one": "Đã sắp xếp (#{{count}})", + "Sorted (#{{count}})_other": "Đã sắp xếp (#{{count}})", + "Unfreeze all columns": "Hủy cố định tất cả các cột", + "Unfreeze {{count}} columns_one": "Hủy cố định cột này", + "Unfreeze {{count}} columns_other": "Hủy cố định {{count}} cột", + "Insert column to the left": "Thêm một cột ở bên trái", + "Insert column to the right": "Thêm một cột ở bên phải", + "Apply on record changes": "Áp dụng thay đổi cho bản lưu", + "Apply to new records": "Áp dụng cho bản lưu mới", + "Authorship": "Quyền tác giả", + "Created At": "Tạo vào lúc", + "Created By": "Tạo bởi", + "Hidden Columns": "Cột đang ẩn", + "Last Updated At": "Cập nhật lần cuối vào", + "Last Updated By": "Lần cuối cập nhập bởi", + "Lookups": "Tra cứu", + "Shortcuts": "Phím tắt", + "Show hidden columns": "Hiển thị các cột ẩn", + "no reference column": "Không thẩm quyền chỉnh sửa cột này", + "Adding UUID column": "Thêm UUID cho cột", + "Adding duplicates column": "Thêm cột trùng lặp", + "Detect Duplicates in...": "Dò ra trùng lặp trong", + "Duplicate in {{- label}}": "Sao chép vào {{- label}}", + "No reference columns.": "Cột không có thẩm quyền thay đổi", + "Search columns": "Tìm cột", + "UUID": "Định Dạng Số Duy Nhất Toàn Cầu", + "Add column with type": "Thêm cột với loại", + "Add formula column": "Thêm cột công thức", + "Add column": "Thêm cột", + "Created at": "Đã được tạo lúc", + "Created by": "Được tạo bởi", + "Detect duplicates in...": "Dò ra các bản sao trong", + "Last updated at": "Cập nhập lần cuối vào", + "Last updated by": "Cập nhập lần cuối bởi", + "Any": "Bất kỳ", + "Numeric": "Số", + "Text": "Văn bản", + "Integer": "Số nguyên", + "Toggle": "Chuyển đổi", + "Date": "Ngày", + "DateTime": "Ngày giờ", + "Choice": "Lựa chọn", + "Choice List": "Danh sách lựa chọn", + "Reference": "Phản hồi", + "Reference List": "Danh sách phản hồi", + "Attachment": "Tập tin đính kèm", + "Filter Data": "Lọc dữ liệu", + "Timestamp": "Dấu thời gian" + }, + "GristDoc": { + "Added new linked section to view {{viewName}}": "Thêm phần được liên kết mới để xem {{viewName}}", + "Import from file": "Nhập từ tệp", + "Saved linked section {{title}} in view {{name}}": "Phần được liên kết {{tilte}} trong chế độ xem {{name}}", + "go to webhook settings": "Vào trang cài đặt của webhook" + }, + "HomeIntro": { + "Any documents created in this site will appear here.": "Các tài liệu được tạo trong trang trang web này sẽ được xuất hiện ở đây.", + "Browse Templates": "Duyệt các mẫu.", + "Create Empty Document": "Tạo tài liệu trống", + "Get started by creating your first Grist document.": "Bắt đầu bằng việc tạo tài liệu Grist đầu tiên của bạn", + "Get started by exploring templates, or creating your first Grist document.": "Bắt đầu bằng việc khám phá các mẫu hoặc tạo tài liệu Grist đầu tiên của bạn", + "Get started by inviting your team and creating your first Grist document.": "Bắt đầu bằng việc mời nhóm của bạn và tạo tài liệu Grist đầu tiên của bạn", + "Help Center": "trung tâm Trợ giúp", + "Import Document": "Nhập Tài liệu", + "Interested in using Grist outside of your team? Visit your free ": "Quan tâm tới việc sử dụng Grist bên ngoài nhóm của bạn? Truy cập miễn phí ", + "Invite Team Members": "Mời thêm thành viên vào nhóm", + "Sign up": "Đăng ký", + "Sprouts Program": "Chương trình Sprouts", + "This workspace is empty.": "Không gian học tập đang trống", + "Visit our {{link}} to learn more.": "Truy cập {{link}} của chúng tôi đẻ tìm hiểu thêm", + "Welcome to Grist!": "Chào mừng bạn đến với Grist!", + "Welcome to {{orgName}}": "Chào mừng bạn đến với {{orgName}}", + "You have read-only access to this site. Currently there are no documents.": "Bạn chỉ quyền truy cập để đọc ở trang web này. Hiện tại trong còn tệp nào nữa", + "personal site": "trang web cá nhân", + "Welcome to Grist, {{- name}}!": "Chào mừng bạn đến với Grist, {{- name}}!", + "Welcome to {{- orgName}}": "Chào mừng bạn đến với {{- orgName}}", + "Sign in": "Đăng nhập", + "To use Grist, please either sign up or sign in.": "Để có thể sử dụng Grist, vui lòng đăng kí hoặc đăng nhập", + "Visit our {{link}} to learn more about Grist.": "Truy cập {{link}} của chúng tôi để có thể tìm hiểu thêm về Grist", + "Welcome to Grist, {{name}}!": "Chào mừng bạn đến với Grist, {{name}}!", + "{{signUp}} to save your work. ": "{{signUp}} để lưu bài làm của bạn " + }, + "HomeLeftPane": { + "Access Details": "Chi tiết truy cập", + "All Documents": "Toàn bộ tài liệu", + "Create Empty Document": "Tạo tài liệu trống", + "Create Workspace": "Tạo không gian học tập", + "Delete": "Xóa", + "Delete {{workspace}} and all included documents?": "Xóa {{workspace}} và bao gồm các tài liệu đi kèm?", + "Examples & Templates": "Mẫu", + "Import Document": "Nhập Tài liệu", + "Manage Users": "Quản lí người dùng", + "Rename": "Đổi tên", + "Trash": "Thùng rác", + "Workspace will be moved to Trash.": "Không gian học tập sẽ được chuyển vào thùng rác", + "Tutorial": "Hướng dẫn", + "Workspaces": "Không gian học tập" + }, + "Importer": { + "Merge rows that match these fields:": "Hợp nhất các lựa chọn trùng với những tệp này:", + "Select fields to match on": "Chọn các tệp trùng với", + "Update existing records": "Cập nhập các bản lưu đang có", + "{{count}} unmatched field in import_one": "{{count}} trường chưa khớp trong quá trình nhập", + "{{count}} unmatched field in import_other": "{{count}} lĩnh vực chưa khớp trong quá trình nhập", + "{{count}} unmatched field_one": "{{count}} lĩnh vực chưa khớp", + "{{count}} unmatched field_other": "{{count}} lĩnh vực chưa khớp", + "Column Mapping": "Ánh xạ cột", + "Column mapping": "Ánh xạ cột", + "Destination table": "Bảng đích", + "Grist column": "cột Grist", + "Import from file": "Nhập từ tệp", + "New Table": "Bảng mới", + "Revert": "Hoàn lại", + "Skip": "Bỏ qua", + "Skip Import": "Bỏ qua bước nhập", + "Source column": "Cột nguồn", + "Skip Table on Import": "Bỏ qua bảng khi nhập" + }, + "LeftPanelCommon": { + "Help Center": "trung tâm Trợ giúp" + }, + "MakeCopyMenu": { + "As Template": "Lưu dưới dạng mẫu", + "Be careful, the original has changes not in this document. Those changes will be overwritten.": "Hãy cẩn thận, bản gốc có những thay đổi không có trong tài liệu này. Những thay đổi đó sẽ bị ghi đè.", + "Enter document name": "Nhập tên tài liệu", + "However, it appears to be already identical.": "Tuy nhiên, nó dường như đã giống hệt nhau.", + "It will be overwritten, losing any content not in this document.": "Nó sẽ bị ghi đè, mất đi những nội dung không có trong tài liệu này.", + "Name": "Tên", + "No destination workspace": "Không có không gian làm việc tại điểm đến", + "Organization": "Tổ chức", + "Original Has Modifications": "Bản gốc có sửa đổi", + "Original Looks Unrelated": "Ngoại hình ban đầu không liên quan", + "Original Looks Identical": "Ngoại hình ban đầu giống hệt nhau", + "Replacing the original requires editing rights on the original document.": "Việc thay thế bản gốc yêu cầu quyền chỉnh sửa trên tài liệu gốc.", + "Sign up": "Đăng ký", + "The original version of this document will be updated.": "Phiên bản gốc của tài liệu này sẽ được cập nhật.", + "To save your changes, please sign up, then reload this page.": "Để lưu các thay đổi của bạn, vui lòng đăng ký, sau đó tải lại trang này.", + "Update": "Cập nhập", + "Update Original": "Cập nhật bản gốc", + "Workspace": "Không gian làm việc", + "You do not have write access to the selected workspace": "Bạn không có quyền ghi vào không gian làm việc chưa chọn", + "You do not have write access to this site": "Bạn không có quyền ghi vào trang web này", + "Download full document and history": "Tải xuống toàn bộ tài liệu và lịch sử", + "Remove all data but keep the structure to use as a template": "Xóa tất cả dữ liệu nhưng giữ cấu trúc để sử dụng làm mẫu", + "Remove document history (can significantly reduce file size)": "Xóa lịch sử tài liệu (có thể làm giảm đáng kể kích thước tệp)", + "Download": "Tải xuống", + "Download document": "Tải xuống tài liệu PDF", + "Cancel": "Hủy", + "Overwrite": "Ghi đè", + "Include the structure without any of the data.": "Bao gồm cấu trúc mà không có bất kỳ dữ liệu nào." + }, + "NotifyUI": { + "Ask for help": "Nhờ giúp đỡ", + "Cannot find personal site, sorry!": "Không thể tìm thấy trang web cá nhân, xin lỗi!", + "Give feedback": "Đưa ra phản hồi", + "Go to your free personal site": "Truy cập trang web cá nhân miễn phí của bạn", + "No notifications": "Không có thông báo", + "Notifications": "Thông báo", + "Renew": "Làm mới", + "Report a problem": "Báo cáo sự cố", + "Upgrade Plan": "Gói nâng cấp", + "Manage billing": "Quản lý thanh toán" + }, + "OnBoardingPopups": { + "Finish": "Hoàn thành", + "Next": "Tiếp theo" + }, + "OpenVideoTour": { + "Grist Video Tour": "Video giới thiệu về grist", + "Video Tour": "Video giới thiệu", + "YouTube video player": "Trình phát video YouTube" + }, + "PageWidgetPicker": { + "Add to Page": "Thêm vào trang", + "Building {{- label}} widget": "Xây dựng tiện ích {{- label}}", + "Group by": "Nhóm của", + "Select Data": "Chọn dữ liệu", + "Select Widget": "Chọn khu vực widget" + }, + "Pages": { + "The following tables will no longer be visible_one": "Bảng sau đây sẽ không còn hiển thị nữa", + "The following tables will no longer be visible_other": "Các bảng sau đây sẽ không còn hiển thị nữa", + "Delete": "Xóa", + "Delete data and this page.": "Xóa dữ liệu và trang này." + }, + "PermissionsWidget": { + "Deny All": "Từ chối tất cả", + "Read Only": "Chỉ đọc", + "Allow All": "Cho phép tất cả" + }, + "PluginScreen": { + "Import failed: ": "Không nhập được " + }, + "RecordLayout": { + "Updating record layout.": "Cập nhật bố cục hồ sơ." + }, + "RecordLayoutEditor": { + "Add Field": "Thêm Field", + "Create New Field": "Tạo trường dữ liệu mới", + "Show field {{- label}}": "Hiển thị trường dữ liệu", + "Save Layout": "Lưu bố cục", + "Cancel": "Hủy" + }, + "RefSelect": { + "Add Column": "Thêm cột", + "No columns to add": "Không có cột nào để thêm" + }, + "RightPanel": { + "CHART TYPE": "LOẠI BIỂU ĐỒ", + "COLUMN TYPE": "LOẠI CỘT", + "CUSTOM": "TÙY CHỈNH", + "Change Widget": "Thay đổi tiện ích", + "Columns_one": "Cột", + "Columns_other": "Nhiều cột", + "DATA TABLE NAME": "TÊN CỦA BẢNG DỮ LIỆU", + "Detach": "Tách rời", + "Edit Data Selection": "Chỉnh sửa việc chọn dữ liệu", + "Fields_one": "Tường thông tin", + "Fields_other": "Các trường thông tin", + "GROUPED BY": "Được nhóm theo", + "ROW STYLE": "KIỂU HÀNG", + "Row Style": "Kiểu hàng", + "SELECT BY": "CHỌN THEO", + "SELECTOR FOR": "Chọn lọc cho", + "SOURCE DATA": "DỮ LIỆU NGUỒN", + "Select Widget": "Chọn khu vực widget", + "Series_one": "Chuỗi", + "Series_other": "Chuỗi", + "Sort & Filter": "Sắp xếp & Lọc", + "TRANSFORM": "CHUYỂN ĐỔI", + "Theme": "Mục", + "WIDGET TITLE": "Tiêu đề của tiện ích con", + "Widget": "Tiện ích con", + "You do not have edit access to this document": "Bạn không có quyền chỉnh sửa tài liệu này", + "Add referenced columns": "Thêm cột tham chiếu", + "Reset form": "Đặt lại biểu mẫu", + "Configuration": "Cấu hình", + "Default field value": "Giá trị mặc định của trường dữ liệu", + "Display button": "Nút hiển thị", + "Layout": "Bố cục", + "Enter text": "Nhập văn bản", + "Field rules": "Quy định về trường dữ liệu", + "Field title": "Tên của trường dữ liệu", + "Hidden field": "Ẩn trường dữ liệu", + "Redirect automatically after submission": "Tự động chuyển hướng sau khi gửi", + "Redirection": "Chuyển hướng", + "Required field": "Trường dữ liệu bắc buộc", + "Submission": "Bài nộp", + "Submit another response": "Gửi một phản hồi khác", + "Submit button label": "Kiểm tra nút của nhãn", + "Success text": "Văn bản đã được gửi thành công", + "Table column name": "Tên của cột trong bảng", + "Enter redirect URL": "Nhập URL để chuyển hướng", + "Data": "DỮ LIỆU", + "Save": "Lưu", + "DATA TABLE": "BẢNG DỮ LIỆU", + "No field selected": "Không có trường dữ liệu nào được chọn", + "Select a field in the form widget to configure.": "Chọn một trường dữ liệu trong tiện ích con để chỉnh sửa" + }, + "RowContextMenu": { + "Copy anchor link": "Sao chép liên kết Anchor", + "Insert row below": "Chèn hàng ở bên dưới", + "Delete": "Xóa", + "Insert row": "Chèn dòng mới", + "Insert row above": "Chèn hàng ở trên", + "View as card": "Xem dưới dạng thẻ", + "Use as table headers": "Sử dụng để làm tiêu đề bảng", + "Duplicate rows_one": "Sao chép dòng, Sao chép hàng", + "Duplicate rows_other": "Các dòng trùng nhau" + }, + "SelectionSummary": { + "Copied to clipboard": "Đã sao chép vào bộ nhớ đệm" + }, + "ShareMenu": { + "Access Details": "Chi tiết truy cập", + "Back to Current": "Quay lại lại nội dung hiện tại", + "Compare to {{termToUse}}": "So sánh với {{termToUse}}", + "Current Version": "Phiên bản hiện tại", + "Download": "Tải xuống", + "Edit without affecting the original": "Chỉnh sửa nhưng không ảnh hưởng đến bản gốc", + "Duplicate Document": "Nhân đôi trang tính", + "Export CSV": "Xuất ra dưới dạng CVS", + "Export XLSX": "Xuất ra dưới dạng XLSX", + "Manage Users": "Quản lí người dùng", + "Replace {{termToUse}}...": "Thay thế {{termToUse}}…", + "Return to {{termToUse}}": "Quay lại {{termToUse}}", + "Save Copy": "Lưu bản sao", + "Save Document": "Lưu tài liệu", + "Send to Google Drive": "Gửi đến Google Drive", + "Show in folder": "Hiển thị trong thư mục", + "Unsaved": "Chưa được lưu", + "Work on a Copy": "làm việc trên một bản sao", + "Share": "Chia sẻ", + "Download...": "Tải xuống", + "Original": "Bản gốc", + "Comma Separated Values (.csv)": "Các giá trị được tách bằng dấu phẩy (.csv)", + "DOO Separated Values (.dsv)": "Giá trị được tách bằng DOO (.dsv)", + "Export as...": "Xuất dưới dạng...", + "Microsoft Excel (.xlsx)": "Microsofl Excel (.xlsx)", + "Tab Separated Values (.tsv)": "TSV (.tsv)" + }, + "SiteSwitcher": { + "Create new team site": "Tạo một nhóm làm việc mới", + "Switch Sites": "Chuyển trang" + }, + "SortConfig": { + "Add Column": "Thêm cột", + "Empty values last": "Giá trị rỗng", + "Natural sort": "Phân loại tự nhiên", + "Update Data": "Cập nhật dữ liệu", + "Use choice position": "Lựa chọn vị trí sử dụng", + "Search Columns": "Tìm cột" + }, + "SortFilterConfig": { + "Filter": "BỘ LỌC", + "Sort": "SẮP XẾP", + "Update Sort & Filter settings": "Cập nhật sắp xếp & Thiết lập bộ lọc", + "Revert": "Hoàn lại", + "Save": "Lưu" + }, + "ThemeConfig": { + "Appearance ": "Chế độ ", + "Switch appearance automatically to match system": "Tự động chuyển đổi giao diện để khớp với hệ thống" + }, + "Tools": { + "Access Rules": "Quy tắc truy cập", + "Delete": "Xóa", + "Delete document tour?": "Xóa tham khảo thư mục", + "Document History": "Lịch sử dữ liệu", + "How-to Tutorial": "Hướng dẫn cách thực hiện", + "Raw Data": "Dữ liệu gốc", + "Return to viewing as yourself": "Quay lại chế độ xem dưới dạng bản thân", + "TOOLS": "CÔNG CỤ", + "Tour of this Document": "Tham khảo thư mục này", + "Validate Data": "Xác thực dữ liệu", + "Settings": "Cài đặt", + "API Console": "Bảng điều khiển API", + "Code View": "Hiển thị mã" + }, + "TopBar": { + "Manage Team": "Quản lý nhóm" + }, + "TriggerFormulas": { + "Any field": "Bất kỳ trường dữ liệu nào", + "Apply on changes to:": "Áp dụng thay đổi cho:", + "Apply on record changes": "Áp dụng thay đổi cho bản lưu", + "Apply to new records": "Áp dụng cho bản lưu mới", + "Cancel": "Hủy", + "Close": "Đóng", + "Current field ": "Trường dữ liệu hiện tại ", + "OK": "Được, chốt" + }, + "TypeTransformation": { + "Update formula (Shift+Enter)": "Cập nhật công thức (Shift+Enter)", + "Apply": "Áp dụng", + "Cancel": "Hủy", + "Preview": "Xem trước", + "Revise": "Xem lại" + }, + "UserManagerModel": { + "Editor": "Trình chỉnh sửa", + "In Full": "Toàn bộ", + "No Default Access": "Không có quyền truy cập mặc định", + "None": "Không có", + "Owner": "Chủ sở hữu", + "View & Edit": "Xem & Chỉnh sửa", + "View Only": "Chỉ xem", + "Viewer": "người xem" + }, + "ValidationPanel": { + "Rule {{length}}": "Quy tắc {{length}}", + "Update formula (Shift+Enter)": "Cập nhật công thức (Shift+Enter)" + }, + "ViewAsBanner": { + "UnknownUser": "Người dùng không xác định" + }, + "ViewConfigTab": { + "Advanced settings": "Cài đặt nâng cao", + "Make On-Demand": "Tạo theo yêu cầu", + "Plugin: ": "phần mở rộng ", + "Section: ": "Bộ phận: ", + "Unmark On-Demand": "Bỏ đánh dấu theo yêu cầu", + "Big tables may be marked as \"on-demand\" to avoid loading them into the data engine.": "Các bảng có kích thước lớn có thể được đánh dấu là 'theo yêu cầu' để tránh việc tải chúng vào bộ động cơ dữ liệu.", + "Blocks": "Khối", + "Compact": "Thu gọn", + "Edit Card Layout": "Chỉnh sửa bố cục thẻ", + "Form": "Biểu mẫu" + }, + "ViewLayoutMenu": { + "Advanced Sort & Filter": "Sắp xếp và Lọc Nâng cao", + "Copy anchor link": "Sao chép liên kết Anchor", + "Data selection": "Chọn lọc dữ liệu", + "Delete record": "Xoá bản ghi", + "Delete widget": "Xóa tiện ích", + "Download as CSV": "Tải xuống dưới dạng CSV", + "Download as XLSX": "Tải xuống dưới dạng XLSX", + "Edit Card Layout": "Chỉnh sửa bố cục thẻ", + "Open configuration": "Mở cấu hình", + "Print widget": "In tiện ích", + "Show raw data": "Hiển thị dữ liệu gốc", + "Widget options": "Tùy chọn tiện ích", + "Add to page": "Thêm vào trang", + "Collapse widget": "Thu nhỏ tiện ích", + "Create a form": "Tạo một biểu mẫu" + }, + "ViewSectionMenu": { + "(customized)": "(Tùy chỉnh)", + "(empty)": "(trống rỗng, không có gì)", + "(modified)": "(Đã chỉnh sửa)", + "Custom options": "Custom options", + "FILTER": "BỘ LỌC", + "Revert": "Hoàn lại", + "SORT": "SẮP XẾP", + "Save": "Lưu", + "Update Sort&Filter settings": "Cập nhật cài đặt cho phân loại và bộ lọc" + }, + "VisibleFieldsConfig": { + "Cannot drop items into Hidden Fields": "Không thể đặt các phần tử trong các trường ẩn", + "Clear": "Xóa", + "Visible {{label}}": "Hiển thị {{label}}", + "Hide {{label}}": "Ẩn {{label}}", + "Hidden {{label}}": "Đã ẩn {{label}}", + "Show {{label}}": "Hiển thị {label}}", + "Hidden Fields cannot be reordered": "Các trường ẩn không thể được sắp xếp lại", + "Select All": "Chọn tất cả" + }, + "WelcomeQuestions": { + "Education": "Giáo dục", + "Finance & Accounting": "Tài chính và kế toán", + "HR & Management": "Quản lý nhân sự", + "IT & Technology": "Công nghệ thông tin", + "Marketing": "Tiếp thị", + "Media Production": "Sản xuất phương tiện truyền thông", + "Other": "Khác", + "Product Development": "Phát triển sản phẩm", + "Research": "Nghiên cứu", + "Sales": "Kinh doanh", + "Type here": "Nhập vào đây", + "Welcome to Grist!": "Chào mừng bạn đến với Grist!", + "What brings you to Grist? Please help us serve you better.": "Điều gì đưa bạn đến Grist? Hãy giúp chúng tôi phục vụ bạn tốt hơn." + }, + "WidgetTitle": { + "Cancel": "Hủy", + "DATA TABLE NAME": "TÊN CỦA BẢNG DỮ LIỆU", + "Override widget title": "Ghi đè tiêu đề tiện ích", + "Provide a table name": "Cung cấp tên cho một bảng", + "WIDGET TITLE": "Tiêu đề của tiện ích con", + "WIDGET DESCRIPTION": "MÔ TẢ TIỆN ÍCH", + "Save": "Lưu" + }, + "breadcrumbs": { + "You may make edits, but they will create a new copy and will\nnot affect the original document.": "Bạn có thể chỉnh sửa, nhưng họ sẽ tạo một bản sao mới và sẽ\nkhông ảnh hưởng đến tài liệu gốc.", + "fiddle": "Thử nghiệm hoặc điều chỉnh", + "override": "Ghi đè", + "recovery mode": "Chế độ khôi phục", + "snapshot": "Lưu nhanh", + "unsaved": "chưa lưu" + }, + "duplicatePage": { + "Duplicate page {{pageName}}": "Trang trùng lặp {{pageName}}", + "Note that this does not copy data, but creates another view of the same data.": "Lưu ý rằng điều này không sao chép dữ liệu, mà tạo ra một cách nhìn khác của cùng dữ liệu đó." + }, + "errorPages": { + "Access denied{{suffix}}": "Quyền truy cập bị từ chối", + "Add account": "Thêm tài khoản", + "Contact support": "Liên hệ hỗ trợ", + "Error{{suffix}}": "Lỗi", + "Go to main page": "Tới trang chính", + "Page not found{{suffix}}": "Không tìm thấy trang", + "Sign in": "Đăng nhập", + "Sign in again": "Đăng nhập lại", + "The requested page could not be found.{{separator}}Please check the URL and try again.": "Không thể tìm thấy trang được yêu cầu. Vui lòng kiểm tra URL và thử lại", + "There was an unknown error.": "Đã xảy ra lỗi không thể xác định", + "You are now signed out.": "Hiện tại bạn đã đăng xuất", + "You are signed in as {{email}}. You can sign in with a different account, or ask an administrator for access.": "Bạn đã đăng nhập với . Bạn có thể truy cập bằng một tâif khoản khác hoặc yêu cầu quản trị viên cấp quyền truy cập", + "You do not have access to this organization's documents.": "Bạn không có quyền truy cập vào tài liệu của tổ chức này", + "Account deleted{{suffix}}": "Đã xóa tài khoản", + "Sign up": "Đăng ký", + "Your account has been deleted.": "Tài khoản của bạn đã bị xóa", + "An unknown error occurred.": "Đã xảy ra lỗi không xác định được.", + "Build your own form": "Tạo biểu mẫu của riêng bạn", + "Form not found": "Không tìm thấy hình", + "Powered by": "Cung cấp bởi", + "Sign in to access this organization's documents.": "Đăng nhập để truy cập tài liệu của tổ chức này", + "Signed out{{suffix}}": "Đã đăng xuất", + "Something went wrong": "Đã xảy ra lỗi", + "There was an error: {{message}}": "Đã xảy ra lỗi" + }, + "menus": { + "* Workspaces are available on team plans. ": "* Không gian làm việc có sẵn trong kế hoạch nhóm. ", + "Select fields": "Chọn trường", + "Upgrade now": "Nâng cấp ngay", + "Any": "Bất kỳ", + "Numeric": "Số", + "Text": "Văn bản", + "Toggle": "Chuyển đổi", + "Date": "Ngày", + "DateTime": "Ngày giờ", + "Choice": "Lựa chọn", + "Choice List": "Danh sách lựa chọn", + "Reference": "Phản hồi", + "Reference List": "Danh sách phản hồi", + "Attachment": "Tập tin đính kèm", + "Search columns": "Tìm cột", + "Integer": "Số nguyên" + }, + "modals": { + "Cancel": "Hủy", + "Ok": "Được, chốt", + "Save": "Lưu", + "Are you sure you want to delete these records?": "Bạn có chắc muốn xoá các bản ghi này?", + "Are you sure you want to delete this record?": "Bạn có chắc chắn muốn xóa dòng này?", + "Delete": "Xóa", + "Dismiss": "Bỏ qua", + "Don't ask again.": "Không hỏi lại", + "Don't show again.": "Không hiển thị lại", + "Don't show tips": "Không hiển thị mẹo", + "Undo to restore": "Hoàn tác để khôi phục", + "Got it": "Hiểu rồi", + "Don't show again": "Không hiển thị lại" + }, + "CellStyle": { + "Default header style": "Kiểu tiêu đề mặc định", + "Header Style": "Kiểu tiêu đề", + "HEADER STYLE": "Kiểu tiêu đề", + "CELL STYLE": "Kiểu ô", + "Cell Style": "Kiểu dáng ô", + "Default cell style": "Kiểu ô mặc định", + "Mixed style": "Phong cách hỗn hợp", + "Open row styles": "Kiểu hàng mở" + }, + "ChoiceTextBox": { + "CHOICES": "Lựa chọn" + }, + "ColumnEditor": { + "COLUMN DESCRIPTION": "Mô tả cột", + "COLUMN LABEL": "Nhãn cột" + }, + "ColumnInfo": { + "COLUMN DESCRIPTION": "Mô tả cột", + "COLUMN ID: ": "ID CỘT: ", + "COLUMN LABEL": "Nhãn cột", + "Cancel": "Hủy", + "Save": "Lưu" + }, + "ConditionalStyle": { + "Add another rule": "Thêm một quy tắc khác", + "Add conditional style": "Thêm kiểu có điều kiện", + "Error in style rule": "Lỗi trong kiểu quy tắc", + "Row Style": "Kiểu hàng", + "Rule must return True or False": "Quy tắc phải trả về Đúng hoặc Sai" + }, + "CurrencyPicker": { + "Invalid currency": "Đơn vị tiền tệ không hợp lệ" + }, + "DiscussionEditor": { + "Cancel": "Hủy", + "Comment": "Bình luận", + "Edit": "Chỉnh sửa", + "Marked as resolved": "Được đánh dấu là đã giải quyết", + "Only current page": "Chỉ trang hiện tại", + "Only my threads": "Chỉ chủ đề của tôi", + "Open": "Mở", + "Remove": "Gỡ, Xóa", + "Reply": "Trả lời", + "Reply to a comment": "Trả lời một bình luận", + "Resolve": "Giải quyết", + "Save": "Lưu", + "Show resolved comments": "Hiển thị bình luận đã xử lí", + "Showing last {{nb}} comments": "Đang hiển thị bình luận cuối cùng", + "Started discussion": "Đã bắt đầu thảo luận", + "Write a comment": "Viết bình luận" + }, + "EditorTooltip": { + "Convert column to formula": "Chuyển đổi cột thành công thức" + }, + "FieldBuilder": { + "Apply Formula to Data": "Áp dụng công thức cho dữ liệu", + "CELL FORMAT": "ĐỊNH DẠNG Ô", + "Changing multiple column types": "Thay đổi nhiều loại cột", + "Mixed format": "Định dạng hỗn hợp", + "Mixed types": "Các loại hỗn hợp", + "Revert field settings for {{colId}} to common": "Hoàn nguyên cài đặt trường cho {{colld}} thành chung", + "Save field settings for {{colId}} as common": "Lưu cài đặt trường cho {{colld}} vào chung", + "Use separate field settings for {{colId}}": "Sử dụng cài đặt trường riêng cho {{colId}}", + "DATA FROM TABLE": "DỮ LIỆU TỪ BẢNG", + "Changing column type": "Thay đổi loại cột" + }, + "FormulaEditor": { + "use AI Assistant": "sử dụng Trợ lý AI", + "Column or field is required": "Cột hoặc trường là bắt buộc", + "Error in the cell": "Lỗi trong ô", + "Errors in all {{numErrors}} cells": "Lỗi trong tất cả ô {{numErrors}}", + "Errors in {{numErrors}} of {{numCells}} cells": "Lỗi trong {{numErrors}}/{{numCells}} của các ô", + "editingFormula is required": "chỉnh sửa công thức là bắt buộc", + "Enter formula or {{button}}.": "Nhập công thức hoặc {{button}}.", + "Enter formula.": "Nhập công thức.", + "Expand Editor": "Mở rộng trình chỉnh sửa" + }, + "HyperLinkEditor": { + "[link label] url": "URL [link label]" + }, + "NumericTextBox": { + "Currency": "Tiền tệ", + "Decimals": "Số Thập Phân", + "Number Format": "Định dạng số", + "Default currency ({{defaultCurrency}})": "Đơn vị tiền tệ mặc định ({{defaultCurrency}})" + }, + "Reference": { + "CELL FORMAT": "ĐỊNH DẠNG Ô", + "SHOW COLUMN": "Hiển Thị Cột Chế Độ (Show Mode Column)", + "Row ID": "ID hàng" + }, + "WelcomeTour": { + "Add New": "Thêm mới, Tạo mới", + "Browse our {{templateLibrary}} to discover what's possible and get inspired.": "Duyệt qua {{templateLibrary}} của chúng tôi để khám phá những gì có thể và lấy cảm hứng.", + "Building up": "xây dựng", + "Configuring your document": "Cấu hình tài liệu của bạn", + "Customizing columns": "Tùy chỉnh cột", + "Double-click or hit {{enter}} on a cell to edit it. ": "Nhấp đúp hoặc nhấn {{enter}} trên một ô để chỉnh sửa. ", + "Editing Data": "Chỉnh sửa dữ liệu", + "Flying higher": "Bay cao hơn", + "Help Center": "trung tâm Trợ giúp", + "Make it relational! Use the {{ref}} type to link tables. ": "Làm cho nó quan hệ! Sử dụng loại {{ref}} để liên kết các bảng. ", + "Set formatting options, formulas, or column types, such as dates, choices, or attachments. ": "Đặt các tùy chọn định dạng, công thức hoặc loại cột, chẳng hạn như ngày tháng, lựa chọn hoặc tệp đính kèm. ", + "Share": "Chia sẻ", + "Sharing": "Chia sẻ", + "Start with {{equal}} to enter a formula.": "Bắt đầu bằng {{equal}} để nhập công thức.", + "Toggle the {{creatorPanel}} to format columns, ": "Chuyển đổi {{creatorPanel}} thành định dạng cột, ", + "Use the Share button ({{share}}) to share the document or export data.": "Sử dụng nút Chia sẻ ({{share}}) để chia sẻ tài liệu hoặc xuất dữ liệu.", + "Use {{addNew}} to add widgets, pages, or import more data. ": "Sử dụng {{addNew}} để thêm tiện ích, trang hoặc nhập thêm dữ liệu. ", + "Use {{helpCenter}} for documentation or questions.": "Sử dụng {{helpCenter}} cho tài liệu hoặc câu hỏi.", + "convert to card view, select data, and more.": "chuyển đổi sang chế độ xem thẻ, chọn dữ liệu và hơn thế nữa.", + "creator panel": "bảng điều khiển cho người sáng tạo", + "template library": "Thư viện mẫu", + "Enter": "Nhập", + "Reference": "Phản hồi", + "Welcome to Grist!": "Chào mừng bạn đến với Grist!" + }, + "GristTooltips": { + "Apply conditional formatting to cells in this column when formula conditions are met.": "Áp dụng định dạng có điều kiện cho các ô trong cột này khi các điều kiện công thức được đáp ứng.", + "Apply conditional formatting to rows based on formulas.": "Áp dụng định dạng có điều kiện cho các hàng dựa trên công thức.", + "Cells in a reference column always identify an {{entire}} record in that table, but you may select which column from that record to show.": "Các ô trong cột tham chiếu luôn xác định một bản ghi {{entire}} trong bảng đó, nhưng bạn có thể chọn cột nào từ bản ghi đó để hiển thị.", + "Click on “Open row styles” to apply conditional formatting to rows.": "Nhấp vào “Open row styles” để áp dụng định dạng có điều kiện cho các hàng.", + "Click the Add New button to create new documents or workspaces, or import data.": "Nhấp vào nút Thêm mới để tạo tài liệu hoặc không gian làm việc mới hoặc nhập dữ liệu.", + "Learn more.": "Tìm hiểu thêm.", + "Link your new widget to an existing widget on this page.": "Liên kết tiện ích con mới của bạn với một tiện ích con hiện có trên trang này.", + "Linking Widgets": "Các tiện ích liên kết", + "Only those rows will appear which match all of the filters.": "Chỉ những hàng đó sẽ xuất hiện khớp với tất cả các bộ lọc.", + "Pinned filters are displayed as buttons above the widget.": "Bộ lọc đã ghim được hiển thị dưới dạng các nút phía trên tiện ích.", + "Raw Data page": "Trang Dữ liệu thô", + "Rearrange the fields in your card by dragging and resizing cells.": "Sắp xếp lại các trường trong thẻ của bạn bằng cách kéo và thay đổi kích thước ô.", + "Reference Columns": "Cột tham chiếu", + "Select the table containing the data to show.": "Chọn bảng chứa dữ liệu cần hiển thị.", + "Select the table to link to.": "Chọn bảng để liên kết đến", + "Selecting Data": "Chọn dữ liệu", + "The Raw Data page lists all data tables in your document, including summary tables and tables not included in page layouts.": "Trang dữ liệu thô liệt kê tất cả các bảng dữ liệu trong tài liệu của bạn, bao gồm các bảng tóm tắt và các bảng không có trong bố cục trang.", + "The total size of all data in this document, excluding attachments.": "Tổng kích thước của tất cả dữ liệu trong tài liệu này, không bao gồm tệp đính kèm", + "They allow for one record to point (or refer) to another.": "Chúng cho phép một bản ghi trỏ (hoặc giới thiệu) đến một bản ghi khác.", + "This is the secret to Grist's dynamic and productive layouts.": "Đây là bí quyết để bố cục năng động và hiệu quả của Grist.", + "Updates every 5 minutes.": "Cập nhật 5 phút một lần.", + "Use the \\u{1D6BA} icon to create summary (or pivot) tables, for totals or subtotals.": "Sử dụng biểu tượng\\u{1D6BA} để tạo các bảng tóm tắt (hoặc xoay vòng), cho tổng hoặc tổng phụ.", + "Useful for storing the timestamp or author of a new record, data cleaning, and more.": "Hữu ích để lưu trữ dấu thời gian hoặc tác giả của bản ghi mới, làm sạch dữ liệu và hơn thế nữa.", + "You can filter by more than one column.": "Bạn có thể lọc nhiều hơn một cột.", + "entire": "toàn bộ", + "relational": "Mối quan hệ", + "Access Rules": "Quy tắc truy cập", + "Access rules give you the power to create nuanced rules to determine who can see or edit which parts of your document.": "Quy tắc truy cập cung cấp cho bạn khả năng tạo các quy tắc sắc thái để xác định ai có thể xem hoặc chỉnh sửa phần nào trong tài liệu của bạn.", + "Add New": "Thêm mới, Tạo mới", + "Use the 𝚺 icon to create summary (or pivot) tables, for totals or subtotals.": "Sử dụng biểu tượng * để tạo các bảng tóm tắt (hoặc xoay vòng), cho tổng số hoặc tổng phụ.", + "Anchor Links": "Liên kết neo", + "Custom Widgets": "Tùy chỉnh Widget", + "To make an anchor link that takes the user to a specific cell, click on a row and press {{shortcut}}.": "Để tạo một liên kết neo đưa người dùng đến một ô cụ thể, hãy nhấp vào một hàng và nhấn {{shortcut}}.", + "You can choose one of our pre-made widgets or embed your own by providing its full URL.": "Bạn có thể chọn một trong các tiện ích được tạo sẵn của chúng tôi hoặc nhúng tiện ích của riêng bạn bằng cách cung cấp URL đầy đủ của tiện ích.", + "Calendar": "Lịch", + "Can't find the right columns? Click 'Change Widget' to select the table with events data.": "Bạn không tìm thấy cột phù hợp? Nhấp vào 'Thay đổi tiện ích' để chọn bảng có dữ liệu sự kiện.", + "To configure your calendar, select columns for start": { + "end dates and event titles. Note each column's type.": "Để định cấu hình lịch của bạn, hãy chọn các cột cho ngày bắt đầu/kết thúc và tiêu đề sự kiện. Lưu ý loại của từng cột." + }, + "A UUID is a randomly-generated string that is useful for unique identifiers and link keys.": "UUID là một chuỗi được tạo ngẫu nhiên hữu ích cho các mã định danh duy nhất và các khóa liên kết.", + "Use reference columns to relate data in different tables.": "Sử dụng các cột tham chiếu để liên kết dữ liệu trong các bảng khác nhau.", + "You can choose from widgets available to you in the dropdown, or embed your own by providing its full URL.": "Bạn có thể chọn từ các tiện ích có sẵn cho bạn trong trình đơn thả xuống hoặc nhúng tiện ích của riêng bạn bằng cách cung cấp URL đầy đủ của tiện ích.", + "Formulas support many Excel functions, full Python syntax, and include a helpful AI Assistant.": "Các công thức hỗ trợ nhiều hàm Excel, cú pháp Python đầy đủ và bao gồm Trợ lý AI hữu ích.", + "Build simple forms right in Grist and share in a click with our new widget. {{learnMoreButton}}": "Xây dựng các biểu mẫu đơn giản ngay trong Grist và chia sẻ chỉ bằng một cú nhấp chuột với tiện ích mới của chúng tôi. {{learnMoreButton}}", + "Forms are here!": "Đã có biểu mẫu!", + "These rules are applied after all column rules have been processed, if applicable.": "Các quy tắc này được áp dụng sau khi tất cả các quy tắc cột đã được xử lý, nếu có.", + "Nested Filtering": "Lọc lồng nhau", + "Pinning Filters": "Bộ lọc ghim", + "Reference columns are the key to {{relational}} data in Grist.": "Các cột tham chiếu là chìa khóa cho dữ liệu {{relational}} trong Grist.", + "Lookups return data from related tables.": "Tra cứu trả về dữ liệu từ các bảng liên quan.", + "Clicking {{EyeHideIcon}} in each cell hides the field from this view without deleting it.": "Nhấp vào {{EyeHideIcon}} trong mỗi ô để ẩn trường khỏi chế độ xem này mà không xóa nó.", + "Editing Card Layout": "Chỉnh sửa bố cục thẻ", + "Formulas that trigger in certain cases, and store the calculated value as data.": "Các công thức kích hoạt trong một số trường hợp nhất định và lưu trữ giá trị được tính toán dưới dạng dữ liệu.", + "Try out changes in a copy, then decide whether to replace the original with your edits.": "Hãy thử các thay đổi trong một bản sao, sau đó quyết định có nên thay thế bản gốc bằng các chỉnh sửa của bạn hay không.", + "Unpin to hide the the button while keeping the filter.": "Bỏ ghim để ẩn nút trong khi vẫn giữ bộ lọc.", + "Learn more": "Tìm hiểu thêm" + }, + "PagePanels": { + "Close Creator Panel": "Đóng bảng điều khiển của người sáng tạo", + "Open Creator Panel": "Mở Bảng điều khiển của người sáng tạo" + }, + "ColumnTitle": { + "COLUMN ID: ": "ID CỘT: ", + "Column ID copied to clipboard": "Đã sao chép ID vào cột khay nhớ tạm", + "Column description": "Sửa mô tả cột", + "Column label": "Nhãn cột", + "Provide a column label": "Cung cấp nhãn cột", + "Save": "Lưu", + "Close": "Đóng", + "Cancel": "Hủy", + "Add description": "Thêm mô tả" + }, + "Clipboard": { + "Got it": "Hiểu rồi", + "Unavailable Command": "Lệnh không khả dụng" + }, + "FieldContextMenu": { + "Clear field": "Xóa trường văn bản", + "Copy": "Sao chép", + "Copy anchor link": "Sao chép liên kết Anchor", + "Cut": "Cắt", + "Hide field": "Ẩn khung này", + "Paste": "Dán (Paste)" + }, + "WebhookPage": { + "Clear Queue": "Xóa hàng đợi", + "Webhook Settings": "Cài đặt Webhook" + }, + "FormulaAssistant": { + "Ask the bot.": "Hỏi bot", + "Capabilities": "Khả năng", + "Community": "Cộng đồng", + "Data": "DỮ LIỆU", + "Formula Cheat Sheet": "Bảng công thức gian lận", + "Formula Help. ": "Trợ giúp về công thức ", + "Function List": "Danh sách chức năng", + "Grist's AI Assistance": "Hỗ trợ AI của Grist", + "Grist's AI Formula Assistance. ": "Hỗ trợ công thức AI của Grist ", + "Need help? Our AI assistant can help.": "Bạn cần trợ giúp? Trợ lý AI của chúng tôi có thể trợ giúp.", + "New Chat": "Cuộc trò chuyện mới", + "Preview": "Xem trước", + "Regenerate": "Tạo lại", + "Save": "Lưu", + "See our {{helpFunction}} and {{formulaCheat}}, or visit our {{community}} for more help.": "Xem {{helpFunction}} và {{formulaCheat}} của chúng tôi hoặc truy cập {{community}} của chúng tôi để được trợ giúp thêm.", + "AI Assistant": "Trợ lí AI", + "Apply": "Áp dụng", + "Cancel": "Hủy", + "Clear Conversation": "Xóa cuộc trò chuyện", + "Code View": "Hiển thị mã", + "Hi, I'm the Grist Formula AI Assistant.": "Xin chào, tôi là Trợ lý AI Công thức Grist.", + "I can only help with formulas. I cannot build tables, columns, and views, or write access rules.": "Tôi chỉ có thể giúp với các công thức. Tôi không thể xây dựng bảng, cột và chế độ xem hoặc viết các quy tắc truy cập.", + "Learn more": "Tìm hiểu thêm", + "Press Enter to apply suggested formula.": "Nhấn Enter để áp dụng công thức được đề xuất.", + "Sign Up for Free": "Đăng kí miễn phí", + "Sign up for a free Grist account to start using the Formula AI Assistant.": "Đăng ký tài khoản Grist miễn phí để bắt đầu sử dụng Trợ lý Công thức AI.", + "There are some things you should know when working with me:": "Có một số điều bạn nên biết khi làm việc với tôi:", + "Formula AI Assistant is only available for logged in users.": "Công thức AI Assistant chỉ khả dụng cho người dùng đã đăng nhập.", + "For higher limits, contact the site owner.": "Để có giới hạn cao hơn nữa, Hãy liên hệ với chủ sở hữu trang website", + "For higher limits, {{upgradeNudge}}.": "Đối với giới hạn cao hơn,Hãy {{upgradeNudge}}.", + "You have used all available credits.": "Bạn đã sử dụng toàn bộ những tài khoản tín dụng có sẵn.", + "You have {{numCredits}} remaining credits.": "Bạn còn lại{{numCredits}} điểm tín dụng.", + "upgrade to the Pro Team plan": "Nâng cấp lên để có gói Pro Team", + "upgrade your plan": "Lên ý tưởng mới cho kế hoạch, dàn ý của bạn", + "Tips": "Mẹo", + "What do you need help with?": "Bạn cần tôi giúp gì ?" + }, + "GridView": { + "Click to insert": "Nhấp để chèn vào." + }, + "WelcomeSitePicker": { + "Welcome back": "Chào mừng bạn trở lại", + "You can always switch sites using the account menu.": "Bạn luôn có thể chuyển đổi trang web bằng menu tài khoản.", + "You have access to the following Grist sites.": "Bạn có quyền truy cập vào các trang Grist sau." + }, + "DescriptionTextArea": { + "DESCRIPTION": "Mô Tả" + }, + "UserManager": { + "Add {{member}} to your team": "Add {{member}} to your team", + "Allow anyone with the link to open.": "Cho phép bất kì ai với liên kết để mở", + "Anyone with link ": "Bất kì ai cũng có thể vào nếu có liên kết ", + "Close": "Đóng", + "Collaborator": "Cộng tác viên", + "Confirm": "Xác nhận", + "Copy Link": "Sao chép liên kết", + "Create a team to share with more people": "Tạo nhóm để chia với nhiều người hơn", + "Grist support": "Tính năng hỗ trợ của grist", + "Guest": "Khách", + "Invite multiple": "Mời thêm nhiều người", + "Invite people to {{resourceType}}": "Mời mọi người tham gia {{resourceType}}", + "Link copied to clipboard": "Đã sao chép liên kết vào bộ nhớ đệm", + "Manage members of team site": "Quản lý các thành viên của trang web nhóm", + "No default access allows access to be granted to individual documents or workspaces, rather than the full team site.": "Không có quyền truy cập mặc định nào cho phép cấp quyền truy cập vào các tài liệu hoặc không gian làm việc riêng lẻ thay vì toàn bộ trang web của nhóm.", + "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}}.": "Khi bạn đã xóa quyền truy cập của chính mình, bạn sẽ không thể lấy lại quyền truy cập đó nếu không có sự trợ giúp từ người khác có đủ quyền truy cập vào {{name}}.", + "Open Access Rules": "Mở các quy tắc để truy cập", + "Outside collaborator": "Cộng tác viên bên ngoài", + "Public Access": "Đường truy cập công khai", + "Public access": "Đường truy cập công khai", + "Public access inherited from {{parent}}. To remove, set 'Inherit access' option to 'None'.": "Quyền truy cập công khai được kế thừa từ {{parent}}. Để xóa, hãy đặt tùy chọn 'Kế thừa quyền truy cập' thành 'Không có'.", + "Public access: ": "Đường truy cập công khai: ", + "Save & ": "Lưu & ", + "Team member": "Thành viên nhóm", + "User inherits permissions from {{parent})}. To remove, set 'Inherit access' option to 'None'.": "Người dùng kế thừa quyền từ {{parent})}. Để xóa, hãy đặt tùy chọn 'Kế thừa quyền truy cập' thành 'Không có'.", + "User may not modify their own access.": "Người dùng có thể không được sửa đổi quyền truy cập của riêng họ.", + "Your role for this team site": "Vai trò của bạn cho trang web nhóm này", + "Your role for this {{resourceType}}": "Vai trò của bạn cho {{resourceType}} này", + "free collaborator": "Cộng tác viên miễn phí", + "guest": "Khách", + "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.": "Người dùng có quyền truy cập xem vào {{resource}} nhờ quyền truy cập được đặt thủ công vào các tài nguyên bên trong. Nếu xóa ở đây, người dùng này sẽ mất quyền truy cập vào tài nguyên bên trong.", + "User inherits permissions from {{parent}}. To remove, set 'Inherit access' option to 'None'.": "Người dùng kế thừa quyền từ {{parent}}. Để xóa, hãy đặt tùy chọn 'Kế thừa quyền truy cập' thành 'Không có'.", + "You are about to remove your own access to this {{resourceType}}": "Bạn sắp xóa quyền truy cập của riêng mình vào {{resourceType}} này", + "Cancel": "Hủy", + "Off": "Tắt", + "On": "Bật", + "Remove my access": "Xóa quyền truy cập của tôi", + "member": "Thành viên", + "team site": "Trang web nhóm", + "{{collaborator}} limit exceeded": "Đã vượt quá giới hạn {{collaborator}}", + "{{limitAt}} of {{limitTop}} {{collaborator}}s": "{{limitAt}} của {{limitTop}} {{collaborator}}s", + "No default access allows access to be granted to individual documents or workspaces, rather than the full team site.": "Không có quyền truy cập mặc định nào cho phép cấp quyền truy cập vào các tài liệu hoặc không gian làm việc, thay vì toàn bộ trang web của nhóm.", + "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}}.": "Khi bạn đã xóa quyền truy cập của riêng mình, bạn sẽ không thể lấy lại quyền truy cập mà không có sự trợ giúp từ người khác có đủ quyền truy cập vào {{resourceType}}." + }, + "SearchModel": { + "Search all tables": "Tìm kiếm tất cả các bảng", + "Search all pages": "Tìm kiếm tất cả các trang" + }, + "searchDropdown": { + "Search": "Tìm kiếm" + }, + "SupportGristNudge": { + "Close": "Đóng", + "Contribute": "Đóng góp, góp phần", + "Help Center": "trung tâm Trợ giúp", + "Opt in to Telemetry": "Chọn trong Đo từ xa", + "Opted In": "Đã chọn trong", + "Support Grist": "Hỗ trợ Grist", + "Support Grist page": "Hỗ trợ của trang grist" + }, + "SupportGristPage": { + "GitHub Sponsors page": "Trang Nhà tài trợ GitHub", + "Help Center": "trung tâm Trợ giúp", + "Home": "Trang đầu", + "Manage Sponsorship": "Quản lý tài trợ", + "Opt in to Telemetry": "Chọn trong Đo từ xa", + "Opt out of Telemetry": "Chọn không tham gia Đo từ xa", + "Sponsor Grist Labs on GitHub": "Nhà tài trợ Grist Labs trên GitHub", + "Support Grist": "Hỗ trợ Grist", + "Telemetry": "Đo từ xa", + "This instance is opted in to telemetry. Only the site administrator has permission to change this.": "Những ví dụ này được chọn trong Đo từ xa. Chỉ quản trị viên trang web mới có quyền thay đổi cái này", + "This instance is opted out of telemetry. Only the site administrator has permission to change this.": "Những ví dụ này được chọn trong Đo từ xa. Chỉ quản trị viên trang web mới có quyền thay đổi cái này", + "We only collect usage statistics, as detailed in our {{link}}, never document contents.": "Chúng tôi chỉ thu thập số liệu thống kê sử dụng, như được nêu chi tiết trong {{link}} của chúng tôi, không bao giờ thu thập nội dung tài liệu.", + "You can opt out of telemetry at any time from this page.": "Bạn có thể chọn không tham gia đo từ xa bất kỳ lúc nào từ trang này.", + "You have opted in to telemetry. Thank you!": "Bạn đã chọn tham gia đo từ xa. Cảm ơn bạn!", + "You have opted out of telemetry.": "Bạn không chọn tham gia đo từ xa.", + "GitHub": "Trang web github" + }, + "buildViewSectionDom": { + "No row selected in {{title}}": "Không có hàng nào được chọn trong {{title}}", + "Not all data is shown": "Không phải tất cả dữ liệu đều được hiển thị", + "No data": "Không có dữ liệu" + }, + "FloatingEditor": { + "Collapse Editor": "Trình soạn thảo thu gọn" + }, + "FloatingPopup": { + "Maximize": "Tối đa", + "Minimize": "Tối thiểu" + }, + "CardContextMenu": { + "Copy anchor link": "Sao chép liên kết Anchor", + "Delete card": "Xoá thẻ", + "Duplicate card": "Nhân đôi thẻ", + "Insert card": "Chèn thẻ", + "Insert card above": "Chèn thẻ ở trên", + "Insert card below": "Chèn thẻ ở dưới" + }, + "HiddenQuestionConfig": { + "Hidden fields": "Ẩn trường" + }, + "WelcomeCoachingCall": { + "free coaching call": "Cuộc gọi tư vấn miễn phí", + "Maybe Later": "Xem sau", + "On the call, we'll take the time to understand your needs and tailor the call to you. We can show you the Grist basics, or start working with your data right away to build the dashboards you need.": "Trong cuộc gọi, chúng tôi sẽ dành thời gian để biết thêm nhu cầu của bạn và điều chỉnh cuộc gọi cho bạn. Chúng tôi có thể cho bạn xem thông tin cơ bản về Grist, hoặc bắt đầu làm việc với dữ liệu của bạn ngay để xây dựng các trang tổng quan bạn cần.", + "Schedule your {{freeCoachingCall}} with a member of our team.": "Đặt lịch {{freeCoachingCall}} với một thành viên trong đội của chúng tôi.", + "Schedule Call": "Đặt lịch gọi" + }, + "FormView": { + "Publish": "Đăng", + "Publish your form?": "Đăng biểu mẫu của bạn?", + "Unpublish": "Bỏ đăng", + "Unpublish your form?": "Bỏ đăng biểu mẫu của bạn?", + "Anyone with the link below can see the empty form and submit a response.": "Bất kỳ ai có liên kết dưới đây đều có thể xem biểu mẫu trống và gửi phản hồi", + "Are you sure you want to reset your form?": "Bạn có chắc chắn muốn làm lại biểu mẫu?", + "Code copied to clipboard": "Mã đã được sao chép vào clipboard", + "Copy code": "Sao chép mã", + "Copy link": "Sao chép liên kết", + "Embed this form": "Nhúng biểu mẫu này", + "Link copied to clipboard": "Đã sao chép liên kết vào bộ nhớ đệm", + "Preview": "Xem trước", + "Reset": "Đặt lại", + "Reset form": "Đặt lại biểu mẫu", + "Save your document to publish this form.": "Lưu tài liệu của bạn để đăng biểu mẫu này.", + "Share": "Chia sẻ", + "Share this form": "Chia sẻ biểu mẫu này", + "View": "Xem" + }, + "Menu": { + "Insert question below": "Chèn câu hỏi bên dưới", + "Paragraph": "Đoạn văn", + "Paste": "Dán (Paste)", + "Separator": "Dấu phân cách", + "Unmapped fields": "Các trường dữ liệu chưa được kết nối", + "Header": "Đầu mục", + "Building blocks": "thành phần cơ bản", + "Columns": "Nhiều cột", + "Copy": "Sao chép", + "Cut": "Cắt", + "Insert question above": "Chèn câu hỏi ở phía trên" + }, + "UnmappedFieldsConfig": { + "Clear": "Xóa", + "Map fields": "Kết nối trường dữ liệu", + "Mapped": "Kết nối", + "Select All": "Chọn tất cả", + "Unmap fields": "Bỏ kết nối các trường", + "Unmapped": "Bỏ kết nối" + }, + "FormConfig": { + "Field rules": "Quy định về trường dữ liệu", + "Required field": "Trường dữ liệu bắc buộc" + }, + "CustomView": { + "Some required columns aren't mapped": "Một số cột bắt buộc được không được kết nối", + "To use this widget, please map all non-optional columns from the creator panel on the right.": "Để sử dụng công cụ này, vui lòng kết nối tất cả các cột không tuỳ chọn từ bảng điều khiển của người sáng tạo ở bên phải." + }, + "FormContainer": { + "Build your own form": "Tạo biểu mẫu của riêng bạn", + "Powered by": "Cung cấp bởi" + }, + "FormErrorPage": { + "Error": "Lỗi" + }, + "FormModel": { + "Oops! The form you're looking for doesn't exist.": "Rất tiếc! Biểu mẫy bạn đang tìm kiếm không tồn tại.", + "Oops! This form is no longer published.": "Rất tiếc! Biểu mẫu này còn được xuất bản nữa.", + "You don't have access to this form.": "Bạn không có quyền truy cập vào biểu mẫu này.", + "There was a problem loading the form.": "Đã có sự cố khi tải biểu mẫu." + }, + "FormPage": { + "There was an error submitting your form. Please try again.": "Đã xãy ra lỗi khi gửi biểu mẫu của bạn. Vui lòng thử lại." + }, + "FormSuccessPage": { + "Form Submitted": "Đã gửi biểu mẫu.", + "Thank you! Your response has been recorded.": "Cảm ơn bạn! Phản hồi của bạn đã được ghi lại." + }, + "DateRangeOptions": { + "Last 30 days": "30 ngày trước", + "Last 7 days": "7 ngày trước", + "Last Week": "Tuần trước", + "Next 7 days": "7 ngày sau", + "This month": "Tháng này", + "This week": "Tuần này", + "This year": "Năm nay", + "Today": "Hôm nay" + }, + "LanguageMenu": { + "Language": "Ngôn ngữ" + }, + "FilterConfig": { + "Add Column": "Thêm cột" + }, + "pages": { + "Duplicate Page": "Trang trùng lặp", + "Remove": "Gỡ, Xóa", + "Rename": "Đổi tên", + "You do not have edit access to this document": "Bạn không có quyền chỉnh sửa tài liệu này" + }, + "search": { + "Find Next ": "Tìm bước kế tiếp ", + "Find Previous ": "Tìm trước đó ", + "No results": "Không có kết quả", + "Search in document": "Tìm kiếm trong tài liệu", + "Search": "Tìm kiếm" + }, + "sendToDrive": { + "Sending file to Google Drive": "Gửi tệp tới google drive" + }, + "NTextBox": { + "false": "Sai", + "true": "true" + }, + "ACLUsers": { + "Example Users": "Ví dụ người dùng", + "Users from table": "Người dùng từ bảng", + "View As": "Xem dưới dạng" + }, + "TypeTransform": { + "Apply": "Áp dụng", + "Cancel": "Hủy", + "Preview": "Xem trước", + "Revise": "Xem lại", + "Update formula (Shift+Enter)": "Cập nhật công thức (Shift+Enter)" + }, + "FieldEditor": { + "It should be impossible to save a plain data value into a formula column": "Không thể lưu giá trị dữ liệu đơn giản vào một cột công thức", + "Unable to finish saving edited cell": "Không thể hoàn tất lưu ô đã chỉnh sửa" + }, + "DescriptionConfig": { + "DESCRIPTION": "Mô Tả" + }, + "Editor": { + "Delete": "Xóa" + }, + "MappedFieldsConfig": { + "Clear": "Xóa", + "Map fields": "Kết nối trường dữ liệu", + "Mapped": "Kết nối", + "Select All": "Chọn tất cả", + "Unmap fields": "Bỏ kết nối các trường", + "Unmapped": "Bỏ kết nối" + }, + "Section": { + "Insert section above": "Chèn phần ở phía trên", + "Insert section below": "Chèn phần ở bên dưới" + } +} diff --git a/static/locales/zh-Hant.client.json b/static/locales/zh_Hant.client.json similarity index 100% rename from static/locales/zh-Hant.client.json rename to static/locales/zh_Hant.client.json diff --git a/stubs/app/client/ui/ProductUpgrades.ts b/stubs/app/client/ui/ProductUpgrades.ts index 2c583135..839a91d7 100644 --- a/stubs/app/client/ui/ProductUpgrades.ts +++ b/stubs/app/client/ui/ProductUpgrades.ts @@ -1 +1 @@ -export * from 'app/client/ui/ProductUpgradesStub'; +export * from 'app/client/ui/CreateTeamModal'; diff --git a/stubs/app/server/declarations.d.ts b/stubs/app/server/declarations.d.ts index ce415255..52903184 100644 --- a/stubs/app/server/declarations.d.ts +++ b/stubs/app/server/declarations.d.ts @@ -28,6 +28,7 @@ declare module "redis" { function createClient(url?: string): RedisClient; class RedisClient { + public readonly connected: boolean; public eval(args: any[], callback?: (err: Error | null, res: any) => void): any; public subscribe(channel: string): void; diff --git a/test/client/lib/SafeBrowser.ts b/test/client/lib/SafeBrowser.ts index 0ff8b5a2..66390338 100644 --- a/test/client/lib/SafeBrowser.ts +++ b/test/client/lib/SafeBrowser.ts @@ -46,7 +46,7 @@ describe('SafeBrowser', function() { browserProcesses = []; sandbox.stub(SafeBrowser, 'createWorker').callsFake(createProcess); - sandbox.stub(SafeBrowser, 'createView').callsFake(createProcess); + sandbox.stub(SafeBrowser, 'createView').callsFake(createProcess as any); sandbox.stub(PluginInstance.prototype, 'getRenderTarget').returns(noop); disposeSpy = sandbox.spy(Disposable.prototype, 'dispose'); }); diff --git a/test/client/lib/dispose.js b/test/client/lib/dispose.js index d60e24ad..75bef429 100644 --- a/test/client/lib/dispose.js +++ b/test/client/lib/dispose.js @@ -153,9 +153,8 @@ describe('dispose', function() { assert.equal(baz.dispose.callCount, 1); assert(baz.dispose.calledBefore(bar.dispose)); - const name = consoleErrors[0][1]; // may be Foo, or minified. - assert(name === 'Foo' || name === 'o'); // this may not be reliable, - // just what I happen to see. + const name = consoleErrors[0][1]; + assert(name === Foo.name); assert.deepEqual(consoleErrors[0], ['Error constructing %s:', name, 'Error: test-error1']); assert.deepEqual(consoleErrors[1], ['Error constructing %s:', name, 'Error: test-error2']); assert.deepEqual(consoleErrors[2], ['Error constructing %s:', name, 'Error: test-error3']); diff --git a/test/gen-server/lib/DocWorkerMap.ts b/test/gen-server/lib/DocWorkerMap.ts new file mode 100644 index 00000000..56421f6f --- /dev/null +++ b/test/gen-server/lib/DocWorkerMap.ts @@ -0,0 +1,56 @@ +// Test for DocWorkerMap.ts + +import { DocWorkerMap } from 'app/gen-server/lib/DocWorkerMap'; +import { DocWorkerInfo } from 'app/server/lib/DocWorkerMap'; +import {expect} from 'chai'; +import sinon from 'sinon'; + +describe('DocWorkerMap', () => { + const sandbox = sinon.createSandbox(); + afterEach(() => { + sandbox.restore(); + }); + + describe('isWorkerRegistered', () => { + const baseWorkerInfo: DocWorkerInfo = { + id: 'workerId', + internalUrl: 'internalUrl', + publicUrl: 'publicUrl', + group: undefined + }; + + [ + { + itMsg: 'should check if worker is registered', + sisMemberAsyncResolves: 1, + expectedResult: true, + expectedKey: 'workers-available-default' + }, + { + itMsg: 'should check if worker is registered in a certain group', + sisMemberAsyncResolves: 1, + group: 'dummygroup', + expectedResult: true, + expectedKey: 'workers-available-dummygroup' + }, + { + itMsg: 'should return false if worker is not registered', + sisMemberAsyncResolves: 0, + expectedResult: false, + expectedKey: 'workers-available-default' + } + ].forEach(ctx => { + it(ctx.itMsg, async () => { + const sismemberAsyncStub = sinon.stub().resolves(ctx.sisMemberAsyncResolves); + const stubDocWorkerMap = { + _client: { sismemberAsync: sismemberAsyncStub } + }; + const result = await DocWorkerMap.prototype.isWorkerRegistered.call( + stubDocWorkerMap, {...baseWorkerInfo, group: ctx.group } + ); + expect(result).to.equal(ctx.expectedResult); + expect(sismemberAsyncStub.calledOnceWith(ctx.expectedKey, baseWorkerInfo.id)).to.equal(true); + }); + }); + }); +}); diff --git a/test/nbrowser/Localization.ts b/test/nbrowser/Localization.ts index b5fb4d40..ce650f13 100644 --- a/test/nbrowser/Localization.ts +++ b/test/nbrowser/Localization.ts @@ -8,6 +8,18 @@ import fs from "fs"; import os from "os"; import path from 'path'; + +// We only support those formats for now: +// en.client.json +// en_US.client.json +// en_US.server.json +// zh_Hant.client.json +// {lang code (+ maybe with underscore and country code}.{namespace}.json +// +// Only this format was tested and is known to work. + +const VALID_LOCALE_FORMAT = /^[a-z]{2,}(_\w+)?\.(\w+)\.json$/; + describe("Localization", function() { this.timeout(60000); setupTestSuite(); @@ -40,20 +52,20 @@ describe("Localization", function() { const langs: Set = new Set(); const namespaces: Set = new Set(); for (const file of fs.readdirSync(localeDirectory)) { - if (file.endsWith(".json")) { - const langRaw = file.split('.')[0]; - const lang = langRaw?.replace(/_/g, '-'); - const ns = file.split('.')[1]; - const clientFile = path.join(localeDirectory, - `${langRaw}.client.json`); - const clientText = fs.readFileSync(clientFile, { encoding: 'utf8' }); - if (!clientText.includes('Translators: please translate this only when')) { - // Translation not ready if this key is not present. - continue; - } - langs.add(lang); - namespaces.add(ns); + // Make sure we see only valid files. + assert.match(file, VALID_LOCALE_FORMAT); + const langRaw = file.split('.')[0]; + const lang = langRaw?.replace(/_/g, '-'); + const ns = file.split('.')[1]; + const clientFile = path.join(localeDirectory, + `${langRaw}.client.json`); + const clientText = fs.readFileSync(clientFile, { encoding: 'utf8' }); + if (!clientText.includes('Translators: please translate this only when')) { + // Translation not ready if this key is not present. + continue; } + langs.add(lang); + namespaces.add(ns); } assert.deepEqual(gristConfig.supportedLngs.sort(), [...langs].sort()); assert.deepEqual(gristConfig.namespaces.sort(), [...namespaces].sort()); @@ -90,6 +102,8 @@ describe("Localization", function() { const enResponse = await (await fetch(homeUrl)).text(); const uzResponse = await (await fetch(homeUrl, {headers: {"Accept-Language": "uz-UZ,uz;q=1"}})).text(); const ptResponse = await (await fetch(homeUrl, {headers: {"Accept-Language": "pt-PR,pt;q=1"}})).text(); + // We have file with nb_NO code, but still this should be preloaded. + const noResponse = await (await fetch(homeUrl, {headers: {"Accept-Language": "nb-NO,nb;q=1"}})).text(); function present(response: string, ...langs: string[]) { for (const lang of langs) { @@ -107,6 +121,7 @@ describe("Localization", function() { present(enResponse, "en"); present(uzResponse, "en"); present(ptResponse, "en"); + present(noResponse, "en"); // Other locales are not preloaded for English. notPresent(enResponse, "uz", "un-UZ", "en-US"); @@ -117,6 +132,9 @@ describe("Localization", function() { notPresent(uzResponse, "uz-UZ"); notPresent(ptResponse, "pt-PR", "uz", "en-US"); + + // For no-NO we have nb_NO file. + present(noResponse, "nb_NO"); }); it("loads correct languages from file system", async function() { diff --git a/test/nbrowser/WebhookOverflow.ts b/test/nbrowser/WebhookOverflow.ts index df52ca15..2d2ee11e 100644 --- a/test/nbrowser/WebhookOverflow.ts +++ b/test/nbrowser/WebhookOverflow.ts @@ -34,6 +34,7 @@ describe('WebhookOverflow', function () { enabled: true, name: 'test webhook', tableId: 'Table2', + watchedColIds: [] }; await docApi.addWebhook(webhookDetails); await docApi.addWebhook(webhookDetails); diff --git a/test/nbrowser/WebhookPage.ts b/test/nbrowser/WebhookPage.ts index 940a8139..8c96913e 100644 --- a/test/nbrowser/WebhookPage.ts +++ b/test/nbrowser/WebhookPage.ts @@ -55,6 +55,7 @@ describe('WebhookPage', function () { 'URL', 'Table', 'Ready Column', + 'Filter for changes in these columns (semicolon-separated ids)', 'Webhook Id', 'Enabled', 'Status', @@ -80,15 +81,17 @@ describe('WebhookPage', function () { await gu.waitToPass(async () => { assert.equal(await getField(1, 'Webhook Id'), id); }); - // Now other fields like name and memo are persisted. + // Now other fields like name, memo and watchColIds are persisted. await setField(1, 'Name', 'Test Webhook'); await setField(1, 'Memo', 'Test Memo'); + await setField(1, 'Filter for changes in these columns (semicolon-separated ids)', 'A; B'); await gu.waitForServer(); await driver.navigate().refresh(); await waitForWebhookPage(); await gu.waitToPass(async () => { assert.equal(await getField(1, 'Name'), 'Test Webhook'); assert.equal(await getField(1, 'Memo'), 'Test Memo'); + assert.equal(await getField(1, 'Filter for changes in these columns (semicolon-separated ids)'), 'A;B'); }); // Make sure the webhook is actually working. await docApi.addRows('Table1', {A: ['zig'], B: ['zag']}); diff --git a/test/nbrowser/testServer.ts b/test/nbrowser/testServer.ts index 16e357e2..417ad8dc 100644 --- a/test/nbrowser/testServer.ts +++ b/test/nbrowser/testServer.ts @@ -67,6 +67,9 @@ export class TestServerMerged extends EventEmitter implements IMochaServer { this._starts++; const workerIdText = process.env.MOCHA_WORKER_ID || '0'; if (reset) { + // Make sure this test server doesn't keep using the DB that's about to disappear. + await this.closeDatabase(); + if (process.env.TESTDIR) { this.testDir = path.join(process.env.TESTDIR, workerIdText); } else { diff --git a/test/nbrowser_with_stubs/CreateTeamSite.ts b/test/nbrowser_with_stubs/CreateTeamSite.ts new file mode 100644 index 00000000..5c5bdfda --- /dev/null +++ b/test/nbrowser_with_stubs/CreateTeamSite.ts @@ -0,0 +1,62 @@ +import { assert, driver, Key } from 'mocha-webdriver'; +import * as gu from 'test/nbrowser/gristUtils'; +import { cleanupExtraWindows, setupTestSuite } from 'test/nbrowser/testUtils'; + +describe('Create Team Site', function () { + this.timeout(20000); + cleanupExtraWindows(); + const cleanup = setupTestSuite(); + + before(async function () { + const session = await gu.session().teamSite.login(); + await session.tempNewDoc(cleanup); + }); + + async function openCreateTeamModal() { + await driver.findWait('.test-dm-org', 500).click(); + assert.equal(await driver.find('.test-site-switcher-create-new-site').isPresent(), true); + await driver.find('.test-site-switcher-create-new-site').click(); + } + + async function fillCreateTeamModalInputs(name: string, domain: string) { + await driver.findWait('.test-create-team-name', 500).click(); + await gu.sendKeys(name); + await gu.sendKeys(Key.TAB); + await gu.sendKeys(domain); + } + + async function goToNewTeamSite() { + await driver.findWait('.test-create-team-confirmation-link', 500).click(); + } + + async function getTeamSiteName() { + return await driver.findWait('.test-dm-orgname', 500).getText(); + } + + it('should work using the createTeamModal', async () => { + assert.equal(await driver.find('.test-dm-org').isPresent(), true); + const teamSiteName = await getTeamSiteName(); + assert.equal(teamSiteName, 'Test Grist'); + await openCreateTeamModal(); + assert.equal(await driver.find('.test-create-team-creation-title').isPresent(), true); + + await fillCreateTeamModalInputs("Test Create Team Site", "testteamsite"); + await gu.sendKeys(Key.ENTER); + assert.equal(await driver.findWait('.test-create-team-confirmation', 500).isPresent(), true); + await goToNewTeamSite(); + const newTeamSiteName = await getTeamSiteName(); + assert.equal(newTeamSiteName, 'Test Create Team Site'); + }); + + it('should work only with unique domain', async () => { + await openCreateTeamModal(); + await fillCreateTeamModalInputs("Test Create Team Site 1", "same-domain"); + await gu.sendKeys(Key.ENTER); + await goToNewTeamSite(); + await openCreateTeamModal(); + await fillCreateTeamModalInputs("Test Create Team Site 2", "same-domain"); + await gu.sendKeys(Key.ENTER); + const errorMessage = await driver.findWait('.test-notifier-toast-wrapper ', 500).getText(); + assert.include(errorMessage, 'Domain already in use'); + }); +}); diff --git a/test/server/Comm.ts b/test/server/Comm.ts index 62faa577..51e21a0e 100644 --- a/test/server/Comm.ts +++ b/test/server/Comm.ts @@ -402,20 +402,19 @@ describe('Comm', function() { // Intercept the call to _onClose to know when it occurs, since we are trying to hit a // situation where 'close' and 'failedSend' events happen in either order. - const stubOnClose = sandbox.stub(Client.prototype as any, '_onClose') - .callsFake(async function(this: Client) { - if (!options.closeHappensFirst) { await delay(10); } + const stubOnClose: any = sandbox.stub(Client.prototype as any, '_onClose') + .callsFake(function(this: Client) { eventsSeen.push('close'); - return (stubOnClose as any).wrappedMethod.apply(this, arguments); + return stubOnClose.wrappedMethod.apply(this, arguments); }); // Intercept calls to client.sendMessage(), to know when it fails, and possibly to delay the // failures to hit a particular order in which 'close' and 'failedSend' events are seen by // Client.ts. This is the only reliable way I found to reproduce this order of events. - const stubSendToWebsocket = sandbox.stub(Client.prototype as any, '_sendToWebsocket') + const stubSendToWebsocket: any = sandbox.stub(Client.prototype as any, '_sendToWebsocket') .callsFake(async function(this: Client) { try { - return await (stubSendToWebsocket as any).wrappedMethod.apply(this, arguments); + return await stubSendToWebsocket.wrappedMethod.apply(this, arguments); } catch (err) { if (options.closeHappensFirst) { await delay(100); } eventsSeen.push('failedSend'); diff --git a/test/server/lib/DocApi.ts b/test/server/lib/DocApi.ts index 66f1e0c7..0372e7bb 100644 --- a/test/server/lib/DocApi.ts +++ b/test/server/lib/DocApi.ts @@ -3347,13 +3347,21 @@ function testDocApi() { }); describe('webhooks related endpoints', async function () { - /* - Regression test for old _subscribe endpoint. /docs/{did}/webhooks should be used instead to subscribe - */ - async function oldSubscribeCheck(requestBody: any, status: number, ...errors: RegExp[]) { - const resp = await axios.post( - `${serverUrl}/api/docs/${docIds.Timesheets}/tables/Table1/_subscribe`, - requestBody, chimpy + const serving: Serving = await serveSomething(app => { + app.use(express.json()); + app.post('/200', ({body}, res) => { + res.sendStatus(200); + res.end(); + }); + }, webhooksTestPort); + + /* + Regression test for old _subscribe endpoint. /docs/{did}/webhooks should be used instead to subscribe + */ + async function oldSubscribeCheck(requestBody: any, status: number, ...errors: RegExp[]) { + const resp = await axios.post( + `${serverUrl}/api/docs/${docIds.Timesheets}/tables/Table1/_subscribe`, + requestBody, chimpy ); assert.equal(resp.status, status); for (const error of errors) { @@ -3430,7 +3438,15 @@ function testDocApi() { await postWebhookCheck({webhooks:[{fields: {eventTypes: ["add"], url: "https://example.com"}}]}, 400, /tableId is missing/); await postWebhookCheck({}, 400, /webhooks is missing/); - + await postWebhookCheck({ + webhooks: [{ + fields: { + tableId: "Table1", eventTypes: ["update"], watchedColIds: ["notExisting"], + url: `${serving.url}/200` + } + }] + }, + 403, /Column not found notExisting/); }); @@ -3855,6 +3871,7 @@ function testDocApi() { tableId?: string, isReadyColumn?: string | null, eventTypes?: string[] + watchedColIds?: string[], }) { // Subscribe helper that returns a method to unsubscribe. const data = await subscribe(endpoint, docId, options); @@ -3872,6 +3889,7 @@ function testDocApi() { tableId?: string, isReadyColumn?: string|null, eventTypes?: string[], + watchedColIds?: string[], name?: string, memo?: string, enabled?: boolean, @@ -3883,7 +3901,7 @@ function testDocApi() { eventTypes: options?.eventTypes ?? ['add', 'update'], url: `${serving.url}/${endpoint}`, isReadyColumn: options?.isReadyColumn === undefined ? 'B' : options?.isReadyColumn, - ...pick(options, 'name', 'memo', 'enabled'), + ...pick(options, 'name', 'memo', 'enabled', 'watchedColIds'), }, chimpy ); assert.equal(status, 200); @@ -4407,6 +4425,72 @@ function testDocApi() { await webhook1(); }); + it("should call to a webhook only when columns updated are in watchedColIds if not empty", async () => { // eslint-disable-line max-len + // Create a test document. + const ws1 = (await userApi.getOrgWorkspaces('current'))[0].id; + const docId = await userApi.newDoc({ name: 'testdoc5' }, ws1); + const doc = userApi.getDocAPI(docId); + await axios.post(`${serverUrl}/api/docs/${docId}/apply`, [ + ['ModifyColumn', 'Table1', 'B', { type: 'Bool' }], + ], chimpy); + + const modifyColumn = async (newValues: { [key: string]: any; } ) => { + await axios.post(`${serverUrl}/api/docs/${docId}/apply`, [ + ['UpdateRecord', 'Table1', newRowIds[0], newValues], + ], chimpy); + await delay(100); + }; + const assertSuccessNotCalled = async () => { + assert.isFalse(successCalled.called()); + successCalled.reset(); + }; + const assertSuccessCalled = async () => { + assert.isTrue(successCalled.called()); + await successCalled.waitAndReset(); + }; + + // Webhook with only one watchedColId. + const webhook1 = await autoSubscribe('200', docId, { + watchedColIds: ['A'], eventTypes: ['add', 'update'] + }); + successCalled.reset(); + // Create record, that will call the webhook. + const newRowIds = await doc.addRows("Table1", { + A: [2], + B: [true], + C: ['c1'] + }); + await delay(100); + assert.isTrue(successCalled.called()); + await successCalled.waitAndReset(); + await modifyColumn({ C: 'c2' }); + await assertSuccessNotCalled(); + await modifyColumn({ A: 19 }); + await assertSuccessCalled(); + await webhook1(); // Unsubscribe. + + // Webhook with multiple watchedColIds + const webhook2 = await autoSubscribe('200', docId, { + watchedColIds: ['A', 'B'], eventTypes: ['update'] + }); + successCalled.reset(); + await modifyColumn({ C: 'c3' }); + await assertSuccessNotCalled(); + await modifyColumn({ A: 20 }); + await assertSuccessCalled(); + await webhook2(); + + // Check that empty string in watchedColIds are ignored + const webhook3 = await autoSubscribe('200', docId, { + watchedColIds: ['A', ""], eventTypes: ['update'] + }); + await modifyColumn({ C: 'c4' }); + await assertSuccessNotCalled(); + await modifyColumn({ A: 21 }); + await assertSuccessCalled(); + await webhook3(); + }); + it("should return statistics", async () => { await clearQueue(docId); // Read stats, it should be empty. @@ -4427,6 +4511,7 @@ function testDocApi() { tableId: 'Table1', name: '', memo: '', + watchedColIds: [], }, usage : { status: 'idle', numWaiting: 0, @@ -4444,6 +4529,7 @@ function testDocApi() { tableId: 'Table1', name: '', memo: '', + watchedColIds: [], }, usage : { status: 'idle', numWaiting: 0, @@ -4775,42 +4861,53 @@ function testDocApi() { describe('webhook update', function () { it('should work correctly', async function () { - - async function check(fields: any, status: number, error?: RegExp | string, expectedFieldsCallback?: (fields: any) => any) { - let savedTableId = 'Table1'; const origFields = { tableId: 'Table1', eventTypes: ['add'], isReadyColumn: 'B', name: 'My Webhook', memo: 'Sync store', + watchedColIds: ['A'] }; // subscribe - const webhook = await subscribe('foo', docId, origFields); + const {data} = await axios.post( + `${serverUrl}/api/docs/${docId}/webhooks`, + { + webhooks: [{ + fields: { + ...origFields, + url: `${serving.url}/foo` + } + }] + }, chimpy + ); + const webhooks = data; const expectedFields = { url: `${serving.url}/foo`, - unsubscribeKey: webhook.unsubscribeKey, eventTypes: ['add'], isReadyColumn: 'B', tableId: 'Table1', enabled: true, name: 'My Webhook', memo: 'Sync store', + watchedColIds: ['A'], }; let stats = await readStats(docId); assert.equal(stats.length, 1, 'stats=' + JSON.stringify(stats)); - assert.equal(stats[0].id, webhook.webhookId); - assert.deepEqual(stats[0].fields, expectedFields); + assert.equal(stats[0].id, webhooks.webhooks[0].id); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const {unsubscribeKey, ...fieldsWithoutUnsubscribeKey} = stats[0].fields; + assert.deepEqual(fieldsWithoutUnsubscribeKey, expectedFields); // update const resp = await axios.patch( - `${serverUrl}/api/docs/${docId}/webhooks/${webhook.webhookId}`, fields, chimpy + `${serverUrl}/api/docs/${docId}/webhooks/${webhooks.webhooks[0].id}`, fields, chimpy ); // check resp @@ -4818,14 +4915,13 @@ function testDocApi() { if (resp.status === 200) { stats = await readStats(docId); assert.equal(stats.length, 1); - assert.equal(stats[0].id, webhook.webhookId); + assert.equal(stats[0].id, webhooks.webhooks[0].id); if (expectedFieldsCallback) { expectedFieldsCallback(expectedFields); } - assert.deepEqual(stats[0].fields, {...expectedFields, ...fields}); - if (fields.tableId) { - savedTableId = fields.tableId; - } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const {unsubscribeKey, ...fieldsWithoutUnsubscribeKey} = stats[0].fields; + assert.deepEqual(fieldsWithoutUnsubscribeKey, { ...expectedFields, ...fields }); } else { if (error instanceof RegExp) { assert.match(resp.data.details?.userError || resp.data.error, error); @@ -4835,7 +4931,9 @@ function testDocApi() { } // finally unsubscribe - const unsubscribeResp = await unsubscribe(docId, webhook, savedTableId); + const unsubscribeResp = await axios.delete( + `${serverUrl}/api/docs/${docId}/webhooks/${webhooks.webhooks[0].id}`, chimpy + ); assert.equal(unsubscribeResp.status, 200, JSON.stringify(pick(unsubscribeResp, ['data', 'status']))); stats = await readStats(docId); assert.equal(stats.length, 0, 'stats=' + JSON.stringify(stats)); @@ -4846,11 +4944,13 @@ function testDocApi() { await check({url: "http://example.com"}, 403, "Provided url is forbidden"); // not https // changing table without changing the ready column should reset the latter - await check({tableId: 'Table2'}, 200, '', expectedFields => expectedFields.isReadyColumn = null); - + await check({tableId: 'Table2'}, 200, '', expectedFields => { + expectedFields.isReadyColumn = null; + expectedFields.watchedColIds = []; + }); await check({tableId: 'Santa'}, 404, `Table not found "Santa"`); - await check({tableId: 'Table2', isReadyColumn: 'Foo'}, 200); + await check({tableId: 'Table2', isReadyColumn: 'Foo', watchedColIds: []}, 200); await check({eventTypes: ['add', 'update']}, 200); await check({eventTypes: []}, 400, "eventTypes must be a non-empty array"); diff --git a/test/server/lib/MinIOExternalStorage.ts b/test/server/lib/MinIOExternalStorage.ts index 915484a2..e7e364af 100644 --- a/test/server/lib/MinIOExternalStorage.ts +++ b/test/server/lib/MinIOExternalStorage.ts @@ -48,7 +48,7 @@ describe("MinIOExternalStorage", function () { s3.listObjects.returns(fakeStream); - const extStorage = new MinIOExternalStorage(dummyBucket, dummyOptions, 42, s3); + const extStorage = new MinIOExternalStorage(dummyBucket, dummyOptions, 42, s3 as any); const result = await extStorage.versions(key); assert.deepEqual(result, []); @@ -74,7 +74,7 @@ describe("MinIOExternalStorage", function () { ]); s3.listObjects.returns(fakeStream); - const extStorage = new MinIOExternalStorage(dummyBucket, dummyOptions, 42, s3); + const extStorage = new MinIOExternalStorage(dummyBucket, dummyOptions, 42, s3 as any); // when const result = await extStorage.versions(key); // then @@ -107,7 +107,7 @@ describe("MinIOExternalStorage", function () { let {fakeStream} = makeFakeStream(objectsFromS3); s3.listObjects.returns(fakeStream); - const extStorage = new MinIOExternalStorage(dummyBucket, dummyOptions, 42, s3); + const extStorage = new MinIOExternalStorage(dummyBucket, dummyOptions, 42, s3 as any); // when const result = await extStorage.versions(key); @@ -142,10 +142,10 @@ describe("MinIOExternalStorage", function () { const fakeStream = new stream.Readable({objectMode: true}); const error = new Error("dummy-error"); sandbox.stub(fakeStream, "_read") - .returns(fakeStream) + .returns(fakeStream as any) .callsFake(() => fakeStream.emit('error', error)); s3.listObjects.returns(fakeStream); - const extStorage = new MinIOExternalStorage(dummyBucket, dummyOptions, 42, s3); + const extStorage = new MinIOExternalStorage(dummyBucket, dummyOptions, 42, s3 as any); // when const result = extStorage.versions(key); @@ -154,4 +154,4 @@ describe("MinIOExternalStorage", function () { return assert.isRejected(result, error); }); }); -}); \ No newline at end of file +}); diff --git a/yarn.lock b/yarn.lock index 580c5ad0..70ffd71e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -510,39 +510,40 @@ resolved "https://registry.npmjs.org/@sindresorhus/is/-/is-0.14.0.tgz" integrity sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ== -"@sinonjs/commons@^1", "@sinonjs/commons@^1.2.0", "@sinonjs/commons@^1.3.0", "@sinonjs/commons@^1.7.0": - version "1.8.3" - resolved "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.3.tgz" - integrity sha512-xkNcLAn/wZaX14RPlwizcKicDk9G3F8m2nU3L7Ukm5zBgTwiT0wsoFAHx9Jq56fJA1z/7uKGtCRu16sOUCLIHQ== +"@sinonjs/commons@^2.0.0": + version "2.0.0" + resolved "https://registry.npmjs.org/@sinonjs/commons/-/commons-2.0.0.tgz#fd4ca5b063554307e8327b4564bd56d3b73924a3" + integrity sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg== dependencies: type-detect "4.0.8" -"@sinonjs/formatio@^3.0.0", "@sinonjs/formatio@^3.2.1": - version "3.2.2" - resolved "https://registry.npmjs.org/@sinonjs/formatio/-/formatio-3.2.2.tgz" - integrity sha512-B8SEsgd8gArBLMD6zpRw3juQ2FVSsmdd7qlevyDqzS9WTCtvF55/gAL+h6gue8ZvPYcdiPdvueM/qm//9XzyTQ== +"@sinonjs/commons@^3.0.0": + version "3.0.1" + resolved "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz#1029357e44ca901a615585f6d27738dbc89084cd" + integrity sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ== dependencies: - "@sinonjs/commons" "^1" - "@sinonjs/samsam" "^3.1.0" + type-detect "4.0.8" -"@sinonjs/samsam@^2.1.2": - version "2.1.3" - resolved "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-2.1.3.tgz" - integrity sha512-8zNeBkSKhU9a5cRNbpCKau2WWPfan+Q2zDlcXvXyhn9EsMqgYs4qzo0XHNVlXC6ABQL8fT6nV+zzo5RTHJzyXw== +"@sinonjs/fake-timers@^11.2.2": + version "11.2.2" + resolved "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-11.2.2.tgz#50063cc3574f4a27bd8453180a04171c85cc9699" + integrity sha512-G2piCSxQ7oWOxwGSAyFHfPIsyeJGXYtc6mFbnFA+kRXkiEnTl8c/8jul2S329iFBnDI9HGoeWWAZvuvOkZccgw== + dependencies: + "@sinonjs/commons" "^3.0.0" -"@sinonjs/samsam@^3.1.0": - version "3.3.3" - resolved "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-3.3.3.tgz" - integrity sha512-bKCMKZvWIjYD0BLGnNrxVuw4dkWCYsLqFOUWw8VgKF/+5Y+mE7LfHWPIYoDXowH+3a9LsWDMo0uAP8YDosPvHQ== +"@sinonjs/samsam@^8.0.0": + version "8.0.0" + resolved "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.0.tgz#0d488c91efb3fa1442e26abea81759dfc8b5ac60" + integrity sha512-Bp8KUVlLp8ibJZrnvq2foVhP0IVX2CIprMJPK0vqGqgrDa0OHVKeZyBykqskkrdxV6yKBPmGasO8LVjAKR3Gew== dependencies: - "@sinonjs/commons" "^1.3.0" - array-from "^2.1.1" - lodash "^4.17.15" + "@sinonjs/commons" "^2.0.0" + lodash.get "^4.4.2" + type-detect "^4.0.8" -"@sinonjs/text-encoding@^0.7.1": - version "0.7.1" - resolved "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.1.tgz" - integrity sha512-+iTbntw2IZPb/anVDbypzfQa+ay64MW0Zo8aJ8gZPWMMK6/OubMVb6lUPMagqjOPnmtauXnFCACVl3O7ogjeqQ== +"@sinonjs/text-encoding@^0.7.2": + version "0.7.2" + resolved "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.2.tgz#5981a8db18b56ba38ef0efb7d995b12aa7b51918" + integrity sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ== "@socket.io/component-emitter@~3.1.0": version "3.1.0" @@ -951,10 +952,17 @@ "@types/mime" "*" "@types/node" "*" -"@types/sinon@5.0.5": - version "5.0.5" - resolved "https://registry.npmjs.org/@types/sinon/-/sinon-5.0.5.tgz" - integrity sha512-Wnuv66VhvAD2LEJfZkq8jowXGxe+gjVibeLCYcVBp7QLdw0BFx2sRkKzoiiDkYEPGg5VyqO805Rcj0stVjQwCQ== +"@types/sinon@17.0.3": + version "17.0.3" + resolved "https://registry.npmjs.org/@types/sinon/-/sinon-17.0.3.tgz#9aa7e62f0a323b9ead177ed23a36ea757141a5fa" + integrity sha512-j3uovdn8ewky9kRBG19bOwaZbexJu/XjtkHyjvUgt4xfPFz18dcORIMqnYh66Fx3Powhcr85NT5+er3+oViapw== + dependencies: + "@types/sinonjs__fake-timers" "*" + +"@types/sinonjs__fake-timers@*": + version "8.1.5" + resolved "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.5.tgz#5fd3592ff10c1e9695d377020c033116cc2889f2" + integrity sha512-mQkU2jY8jJEF7YHjHvsQO8+3ughTL1mcnn96igfhONmR+fUPSKIkefQYpSe8bsly2Ep7oQbn/6VG5/9/0qcArQ== "@types/sizzle@*": version "2.3.2" @@ -1581,11 +1589,6 @@ array-flatten@1.1.1: resolved "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz" integrity sha1-ml9pkFGx5wczKPKgCJaLZOopVdI= -array-from@^2.1.1: - version "2.1.1" - resolved "https://registry.npmjs.org/array-from/-/array-from-2.1.1.tgz" - integrity sha1-z+nYwmYoudxa7MYqn12PHzUsEZU= - array-map@~0.0.0: version "0.0.0" resolved "https://registry.npmjs.org/array-map/-/array-map-0.0.0.tgz" @@ -2948,10 +2951,10 @@ diff@5.0.0: resolved "https://registry.yarnpkg.com/diff/-/diff-5.0.0.tgz#7ed6ad76d859d030787ec35855f5b1daf31d852b" integrity sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w== -diff@^3.5.0: - version "3.5.0" - resolved "https://registry.npmjs.org/diff/-/diff-3.5.0.tgz" - integrity sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA== +diff@^5.1.0: + version "5.2.0" + resolved "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz#26ded047cd1179b78b9537d5ef725503ce1ae531" + integrity sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A== diffie-hellman@^5.0.0: version "5.0.3" @@ -4833,11 +4836,6 @@ is-yarn-global@^0.3.0: resolved "https://registry.npmjs.org/is-yarn-global/-/is-yarn-global-0.3.0.tgz" integrity sha512-VjSeb/lHmkoyd8ryPVIKvOCn4D1koMqY+vqyjjUfc3xyKtP4dYOxM44sZrnqQSzSds3xyOrUTLTC9LVCVgLngw== -isarray@0.0.1: - version "0.0.1" - resolved "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz" - integrity sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8= - isarray@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" @@ -5037,10 +5035,10 @@ jszip@^3.10.1, jszip@^3.5.0: readable-stream "~2.3.6" setimmediate "^1.0.5" -just-extend@^4.0.2: - version "4.2.1" - resolved "https://registry.npmjs.org/just-extend/-/just-extend-4.2.1.tgz" - integrity sha512-g3UB796vUFIY90VIv/WX3L2c8CS2MdWUww3CNrYmqza1Fg0DURc2K/O4YrnklBdQarSJ/y8JnJYDGc+1iumQjg== +just-extend@^6.2.0: + version "6.2.0" + resolved "https://registry.npmjs.org/just-extend/-/just-extend-6.2.0.tgz#b816abfb3d67ee860482e7401564672558163947" + integrity sha512-cYofQu2Xpom82S6qD778jBDpwvvy39s1l/hrYij2u9AMdQcGRpaBu6kY4mVhuno5kJVi1DAz4aiphA2WI1/OAw== jwa@^1.4.1: version "1.4.1" @@ -5307,18 +5305,6 @@ log-symbols@4.1.0: chalk "^4.1.0" is-unicode-supported "^0.1.0" -lolex@^3.0.0: - version "3.1.0" - resolved "https://registry.npmjs.org/lolex/-/lolex-3.1.0.tgz" - integrity sha512-zFo5MgCJ0rZ7gQg69S4pqBsLURbFw11X68C18OcJjJQbqaXm2NoTrGl1IMM3TIz0/BnN1tIs2tzmmqvCsOMMjw== - -lolex@^5.0.1: - version "5.1.2" - resolved "https://registry.npmjs.org/lolex/-/lolex-5.1.2.tgz" - integrity sha512-h4hmjAvHTmd+25JSwrtTIuwbKdwg5NzZVRMLn9saij4SZaepCrTCxPr35H/3bjwfMJtN+t3CX8672UIkglz28A== - dependencies: - "@sinonjs/commons" "^1.7.0" - loupe@^2.3.1: version "2.3.6" resolved "https://registry.yarnpkg.com/loupe/-/loupe-2.3.6.tgz#76e4af498103c532d1ecc9be102036a21f787b53" @@ -5796,16 +5782,16 @@ nice-napi@^1.0.2: node-addon-api "^3.0.0" node-gyp-build "^4.2.2" -nise@^1.4.6: - version "1.5.3" - resolved "https://registry.npmjs.org/nise/-/nise-1.5.3.tgz" - integrity sha512-Ymbac/94xeIrMf59REBPOv0thr+CJVFMhrlAkW/gjCIE58BGQdCj0x7KRCb3yz+Ga2Rz3E9XXSvUyyxqqhjQAQ== +nise@^5.1.5: + version "5.1.9" + resolved "https://registry.npmjs.org/nise/-/nise-5.1.9.tgz#0cb73b5e4499d738231a473cd89bd8afbb618139" + integrity sha512-qOnoujW4SV6e40dYxJOb3uvuoPHtmLzIk4TFo+j0jPJoC+5Z9xja5qH5JZobEPsa8+YYphMrOSwnrshEhG2qww== dependencies: - "@sinonjs/formatio" "^3.2.1" - "@sinonjs/text-encoding" "^0.7.1" - just-extend "^4.0.2" - lolex "^5.0.1" - path-to-regexp "^1.7.0" + "@sinonjs/commons" "^3.0.0" + "@sinonjs/fake-timers" "^11.2.2" + "@sinonjs/text-encoding" "^0.7.2" + just-extend "^6.2.0" + path-to-regexp "^6.2.1" node-abort-controller@3.0.1: version "3.0.1" @@ -6237,12 +6223,10 @@ path-to-regexp@0.1.7: resolved "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz" integrity sha1-32BBeABfUi8V60SQ5yR6G/qmf4w= -path-to-regexp@^1.7.0: - version "1.8.0" - resolved "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz" - integrity sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA== - dependencies: - isarray "0.0.1" +path-to-regexp@^6.2.1: + version "6.2.1" + resolved "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.1.tgz#d54934d6798eb9e5ef14e7af7962c945906918e5" + integrity sha512-JLyh7xT1kizaEvcaXOQwOc2/Yhw6KZOvPf1S8401UyLk86CU79LN3vl7ztXGm/pZ+YjoyAJ4rxmHwbkBXJX+yw== path-type@^4.0.0: version "4.0.0" @@ -7209,20 +7193,17 @@ simple-concat@^1.0.0: resolved "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz" integrity sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q== -sinon@7.1.1: - version "7.1.1" - resolved "https://registry.npmjs.org/sinon/-/sinon-7.1.1.tgz" - integrity sha512-iYagtjLVt1vN3zZY7D8oH7dkjNJEjLjyuzy8daX5+3bbQl8gaohrheB9VfH1O3L6LKuue5WTJvFluHiuZ9y3nQ== +sinon@17.0.1: + version "17.0.1" + resolved "https://registry.npmjs.org/sinon/-/sinon-17.0.1.tgz#26b8ef719261bf8df43f925924cccc96748e407a" + integrity sha512-wmwE19Lie0MLT+ZYNpDymasPHUKTaZHUH/pKEubRXIzySv9Atnlw+BUMGCzWgV7b7wO+Hw6f1TEOr0IUnmU8/g== dependencies: - "@sinonjs/commons" "^1.2.0" - "@sinonjs/formatio" "^3.0.0" - "@sinonjs/samsam" "^2.1.2" - diff "^3.5.0" - lodash.get "^4.4.2" - lolex "^3.0.0" - nise "^1.4.6" - supports-color "^5.5.0" - type-detect "^4.0.8" + "@sinonjs/commons" "^3.0.0" + "@sinonjs/fake-timers" "^11.2.2" + "@sinonjs/samsam" "^8.0.0" + diff "^5.1.0" + nise "^5.1.5" + supports-color "^7.2.0" slash@^3.0.0: version "3.0.0" @@ -7502,7 +7483,7 @@ supports-color@^5.3.0, supports-color@^5.5.0: dependencies: has-flag "^3.0.0" -supports-color@^7.1.0: +supports-color@^7.1.0, supports-color@^7.2.0: version "7.2.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==