(core) updates from grist-core

pull/936/head
Paul Fitzpatrick 1 month ago
commit 4567fad947

@ -20,7 +20,7 @@ jobs:
python-version: [3.9] python-version: [3.9]
node-version: [18.x] node-version: [18.x]
tests: tests:
- ':lint:python:client:common:smoke:' - ':lint:python:client:common:smoke:stubs:'
- ':server-1-of-2:' - ':server-1-of-2:'
- ':server-2-of-2:' - ':server-2-of-2:'
- ':nbrowser-^[A-G]:' - ':nbrowser-^[A-G]:'
@ -73,7 +73,7 @@ jobs:
run: yarn run build:prod run: yarn run build:prod
- name: Install chromedriver - 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 run: ./node_modules/selenium-webdriver/bin/linux/selenium-manager --driver chromedriver
- name: Run smoke test - name: Run smoke test
@ -92,6 +92,10 @@ jobs:
if: contains(matrix.tests, ':common:') if: contains(matrix.tests, ':common:')
run: yarn run test: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 - name: Run server tests with minio and redis
if: contains(matrix.tests, ':server-') if: contains(matrix.tests, ':server-')
run: | run: |

@ -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 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 ## Features
Grist is a hybrid database/spreadsheet, meaning that: Grist is a hybrid database/spreadsheet, meaning that:

@ -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<HTMLElement>[]): DomContents;
showUpgradeButton(...args: DomArg<HTMLElement>[]): 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<string>;
domain: Observable<string>;
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};
}
`);

@ -93,7 +93,10 @@ function makeTeamSiteIntro(homeModel: HomeModel) {
cssIntroLine(t("Get started by inviting your team and creating your first Grist document.")), cssIntroLine(t("Get started by inviting your team and creating your first Grist document.")),
(!isFeatureEnabled('helpCenter') ? null : (!isFeatureEnabled('helpCenter') ? null :
cssIntroLine( 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') testId('welcome-text')
) )
), ),

@ -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<HTMLElement>[]): DomContents;
showUpgradeButton(...args: DomArg<HTMLElement>[]): DomContents;
}
export function buildUpgradeButton(owner: IDisposableOwner, app: AppModel): UpgradeButton {
return {
showUpgradeCard : () => null,
showUpgradeButton : () => null,
};
}

@ -149,8 +149,8 @@ export class SupportGristNudge extends Disposable {
cssCenterAlignedHeader(t('Opted In')), cssCenterAlignedHeader(t('Opted In')),
cssParagraph( cssParagraph(
t( t(
'Thank you! Your trust and support is greatly appreciated. ' + 'Thank you! Your trust and support is greatly appreciated.\
'Opt out any time from the {{link}} in the user menu.', Opt out any time from the {{link}} in the user menu.',
{link: adminPanelLink()}, {link: adminPanelLink()},
), ),
), ),

@ -37,7 +37,7 @@ const WEBHOOK_COLUMNS = [
id: 'vt_webhook_fc1', id: 'vt_webhook_fc1',
colId: 'tableId', colId: 'tableId',
type: 'Choice', type: 'Choice',
label: 'Table', label: t('Table'),
// widgetOptions are configured later, since the choices depend // widgetOptions are configured later, since the choices depend
// on the user tables in the document. // on the user tables in the document.
}, },
@ -45,13 +45,13 @@ const WEBHOOK_COLUMNS = [
id: 'vt_webhook_fc2', id: 'vt_webhook_fc2',
colId: 'url', colId: 'url',
type: 'Text', type: 'Text',
label: 'URL', label: t('URL'),
}, },
{ {
id: 'vt_webhook_fc3', id: 'vt_webhook_fc3',
colId: 'eventTypes', colId: 'eventTypes',
type: 'ChoiceList', type: 'ChoiceList',
label: 'Event Types', label: t('Event Types'),
widgetOptions: JSON.stringify({ widgetOptions: JSON.stringify({
widget: 'TextBox', widget: 'TextBox',
alignment: 'left', alignment: 'left',
@ -59,11 +59,17 @@ const WEBHOOK_COLUMNS = [
choiceOptions: {}, choiceOptions: {},
}), }),
}, },
{
id: 'vt_webhook_fc10',
colId: 'watchedColIdsText',
type: 'Text',
label: t('Filter for changes in these columns (semicolon-separated ids)'),
},
{ {
id: 'vt_webhook_fc4', id: 'vt_webhook_fc4',
colId: 'enabled', colId: 'enabled',
type: 'Bool', type: 'Bool',
label: 'Enabled', label: t('Enabled'),
widgetOptions: JSON.stringify({ widgetOptions: JSON.stringify({
widget: 'Switch', widget: 'Switch',
}), }),
@ -72,31 +78,31 @@ const WEBHOOK_COLUMNS = [
id: 'vt_webhook_fc5', id: 'vt_webhook_fc5',
colId: 'isReadyColumn', colId: 'isReadyColumn',
type: 'Text', type: 'Text',
label: 'Ready Column', label: t('Ready Column'),
}, },
{ {
id: 'vt_webhook_fc6', id: 'vt_webhook_fc6',
colId: 'webhookId', colId: 'webhookId',
type: 'Text', type: 'Text',
label: 'Webhook Id', label: t('Webhook Id'),
}, },
{ {
id: 'vt_webhook_fc7', id: 'vt_webhook_fc7',
colId: 'name', colId: 'name',
type: 'Text', type: 'Text',
label: 'Name', label: t('Name'),
}, },
{ {
id: 'vt_webhook_fc8', id: 'vt_webhook_fc8',
colId: 'memo', colId: 'memo',
type: 'Text', type: 'Text',
label: 'Memo', label: t('Memo'),
}, },
{ {
id: 'vt_webhook_fc9', id: 'vt_webhook_fc9',
colId: 'status', colId: 'status',
type: 'Text', type: 'Text',
label: 'Status', label: t('Status'),
}, },
] as const; ] as const;
@ -107,8 +113,8 @@ const WEBHOOK_VIEW_FIELDS: Array<(typeof WEBHOOK_COLUMNS)[number]['colId']> = [
'name', 'memo', 'name', 'memo',
'eventTypes', 'url', 'eventTypes', 'url',
'tableId', 'isReadyColumn', 'tableId', 'isReadyColumn',
'webhookId', 'enabled', 'watchedColIdsText', 'webhookId',
'status' 'enabled', 'status'
]; ];
/** /**
@ -127,9 +133,9 @@ class WebhookExternalTable implements IExternalTable {
public name = 'GristHidden_WebhookTable'; public name = 'GristHidden_WebhookTable';
public initialActions = _prepareWebhookInitialActions(this.name); public initialActions = _prepareWebhookInitialActions(this.name);
public saveableFields = [ public saveableFields = [
'tableId', 'url', 'eventTypes', 'enabled', 'name', 'memo', 'isReadyColumn', 'tableId', 'watchedColIdsText', 'url', 'eventTypes', 'enabled', 'name', 'memo', 'isReadyColumn',
]; ];
public webhooks: ObservableArray<WebhookSummary> = observableArray<WebhookSummary>([]); public webhooks: ObservableArray<UIWebhookSummary> = observableArray<UIWebhookSummary>([]);
public constructor(private _docApi: DocAPI) { public constructor(private _docApi: DocAPI) {
} }
@ -151,7 +157,7 @@ class WebhookExternalTable implements IExternalTable {
} }
const colIds = new Set(getColIdsFromDocAction(d) || []); const colIds = new Set(getColIdsFromDocAction(d) || []);
if (colIds.has('webhookId') || colIds.has('status')) { 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; continue;
} }
await this._removeWebhook(rec); await this._removeWebhook(rec);
reportMessage(`Removed webhook.`); reportMessage(t(`Removed webhook.`));
} }
const updates = new Set(delta.updateRows); const updates = new Set(delta.updateRows);
const t2 = editor; const t2 = editor;
@ -227,6 +233,7 @@ class WebhookExternalTable implements IExternalTable {
for (const webhook of webhooks) { for (const webhook of webhooks) {
const values = _mapWebhookValues(webhook); const values = _mapWebhookValues(webhook);
const rowId = rowMap.get(webhook.id); const rowId = rowMap.get(webhook.id);
if (rowId) { if (rowId) {
toRemove.delete(rowId); toRemove.delete(rowId);
actions.push( actions.push(
@ -269,7 +276,12 @@ class WebhookExternalTable implements IExternalTable {
private _initalizeWebhookList(webhooks: WebhookSummary[]){ private _initalizeWebhookList(webhooks: WebhookSummary[]){
this.webhooks.removeAll(); 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 { private _getErrorString(e: ApiError): string {
@ -308,6 +320,9 @@ class WebhookExternalTable implements IExternalTable {
if (fields.eventTypes) { if (fields.eventTypes) {
fields.eventTypes = without(fields.eventTypes, 'L'); fields.eventTypes = without(fields.eventTypes, 'L');
} }
fields.watchedColIds = fields.watchedColIdsText
? fields.watchedColIdsText.split(";").filter((colId: string) => colId.trim() !== "")
: [];
return fields; return fields;
} }
} }
@ -355,12 +370,12 @@ export class WebhookPage extends DisposableWithEvents {
public async reset() { public async reset() {
await this.docApi.flushWebhooks(); await this.docApi.flushWebhooks();
reportSuccess('Cleared webhook queue.'); reportSuccess(t('Cleared webhook queue.'));
} }
public async resetSelected(id: string) { public async resetSelected(id: string) {
await this.docApi.flushWebhook(id); 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 * Map a webhook summary to a webhook table raw record. The main
* difference is that `eventTypes` is tweaked to be in a cell format, * 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<WebhookSchemaType> { function _mapWebhookValues(webhookSummary: UIWebhookSummary): Partial<WebhookSchemaType> {
const fields = webhookSummary.fields; const fields = webhookSummary.fields;
const {eventTypes} = fields; const {eventTypes, watchedColIdsText} = fields;
const watchedColIds = watchedColIdsText
? watchedColIdsText.split(";").filter(colId => colId.trim() !== "")
: [];
return { return {
...fields, ...fields,
webhookId: webhookSummary.id, webhookId: webhookSummary.id,
status: JSON.stringify(webhookSummary.usage), status: JSON.stringify(webhookSummary.usage),
eventTypes: [GristObjCode.List, ...eventTypes], eventTypes: [GristObjCode.List, ...eventTypes],
watchedColIds: [GristObjCode.List, ...watchedColIds],
}; };
} }
@ -457,6 +477,11 @@ type WebhookSchemaType = {
[prop in keyof WebhookSummary['fields']]: WebhookSummary['fields'][prop] [prop in keyof WebhookSummary['fields']]: WebhookSummary['fields'][prop]
} & { } & {
eventTypes: [GristObjCode, ...unknown[]]; eventTypes: [GristObjCode, ...unknown[]];
watchedColIds: [GristObjCode, ...unknown[]];
status: string; status: string;
webhookId: string; webhookId: string;
} }
type UIWebhookSummary = WebhookSummary & {
fields: {watchedColIdsText?: string;}
}

@ -16,6 +16,7 @@ export const WebhookFields = t.iface([], {
"url": "string", "url": "string",
"eventTypes": t.array(t.union(t.lit("add"), t.lit("update"))), "eventTypes": t.array(t.union(t.lit("add"), t.lit("update"))),
"tableId": "string", "tableId": "string",
"watchedColIds": t.opt(t.array("string")),
"enabled": t.opt("boolean"), "enabled": t.opt("boolean"),
"isReadyColumn": t.opt(t.union("string", "null")), "isReadyColumn": t.opt(t.union("string", "null")),
"name": t.opt("string"), "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([], { export const WebhookSubscribe = t.iface([], {
"url": "string", "url": "string",
"eventTypes": t.array(t.union(t.lit("add"), t.lit("update"))), "eventTypes": t.array(t.union(t.lit("add"), t.lit("update"))),
"watchedColIds": t.opt(t.array("string")),
"enabled": t.opt("boolean"), "enabled": t.opt("boolean"),
"isReadyColumn": t.opt(t.union("string", "null")), "isReadyColumn": t.opt(t.union("string", "null")),
"name": t.opt("string"), "name": t.opt("string"),
@ -47,6 +49,7 @@ export const WebhookSummary = t.iface([], {
"eventTypes": t.array("string"), "eventTypes": t.array("string"),
"isReadyColumn": t.union("string", "null"), "isReadyColumn": t.union("string", "null"),
"tableId": "string", "tableId": "string",
"watchedColIds": t.opt(t.array("string")),
"enabled": "boolean", "enabled": "boolean",
"name": "string", "name": "string",
"memo": "string", "memo": "string",
@ -63,6 +66,7 @@ export const WebhookPatch = t.iface([], {
"url": t.opt("string"), "url": t.opt("string"),
"eventTypes": t.opt(t.array(t.union(t.lit("add"), t.lit("update")))), "eventTypes": t.opt(t.array(t.union(t.lit("add"), t.lit("update")))),
"tableId": t.opt("string"), "tableId": t.opt("string"),
"watchedColIds": t.opt(t.array("string")),
"enabled": t.opt("boolean"), "enabled": t.opt("boolean"),
"isReadyColumn": t.opt(t.union("string", "null")), "isReadyColumn": t.opt(t.union("string", "null")),
"name": t.opt("string"), "name": t.opt("string"),

@ -10,6 +10,7 @@ export interface WebhookFields {
url: string; url: string;
eventTypes: Array<"add"|"update">; eventTypes: Array<"add"|"update">;
tableId: string; tableId: string;
watchedColIds?: string[];
enabled?: boolean; enabled?: boolean;
isReadyColumn?: string|null; isReadyColumn?: string|null;
name?: string; name?: string;
@ -26,6 +27,7 @@ export type WebhookStatus = 'idle'|'sending'|'retrying'|'postponed'|'error'|'inv
export interface WebhookSubscribe { export interface WebhookSubscribe {
url: string; url: string;
eventTypes: Array<"add"|"update">; eventTypes: Array<"add"|"update">;
watchedColIds?: string[];
enabled?: boolean; enabled?: boolean;
isReadyColumn?: string|null; isReadyColumn?: string|null;
name?: string; name?: string;
@ -44,6 +46,7 @@ export interface WebhookSummary {
eventTypes: string[]; eventTypes: string[];
isReadyColumn: string|null; isReadyColumn: string|null;
tableId: string; tableId: string;
watchedColIds?: string[];
enabled: boolean; enabled: boolean;
name: string; name: string;
memo: string; memo: string;
@ -63,6 +66,7 @@ export interface WebhookPatch {
url?: string; url?: string;
eventTypes?: Array<"add"|"update">; eventTypes?: Array<"add"|"update">;
tableId?: string; tableId?: string;
watchedColIds?: string[];
enabled?: boolean; enabled?: boolean;
isReadyColumn?: string|null; isReadyColumn?: string|null;
name?: string; name?: string;

@ -4,7 +4,7 @@ import { GristObjCode } from "app/plugin/GristData";
// tslint:disable:object-literal-key-quotes // tslint:disable:object-literal-key-quotes
export const SCHEMA_VERSION = 41; export const SCHEMA_VERSION = 42;
export const schema = { export const schema = {
@ -167,6 +167,8 @@ export const schema = {
label : "Text", label : "Text",
memo : "Text", memo : "Text",
enabled : "Bool", enabled : "Bool",
watchedColRefList : "RefList:_grist_Tables_column",
options : "Text",
}, },
"_grist_ACLRules": { "_grist_ACLRules": {
@ -388,6 +390,8 @@ export interface SchemaTypes {
label: string; label: string;
memo: string; memo: string;
enabled: boolean; enabled: boolean;
watchedColRefList: [GristObjCode.List, ...number[]]|null;
options: string;
}; };
"_grist_ACLRules": { "_grist_ACLRules": {

@ -24,6 +24,9 @@ const CHECKSUM_TTL_MSEC = 24 * 60 * 60 * 1000; // 24 hours
// How long do permits stored in redis last, in milliseconds. // How long do permits stored in redis last, in milliseconds.
const PERMIT_TTL_MSEC = 1 * 60 * 1000; // 1 minute const PERMIT_TTL_MSEC = 1 * 60 * 1000; // 1 minute
// Default doc worker group.
const DEFAULT_GROUP = 'default';
class DummyDocWorkerMap implements IDocWorkerMap { class DummyDocWorkerMap implements IDocWorkerMap {
private _worker?: DocWorkerInfo; private _worker?: DocWorkerInfo;
private _available: boolean = false; private _available: boolean = false;
@ -62,6 +65,10 @@ class DummyDocWorkerMap implements IDocWorkerMap {
this._available = available; this._available = available;
} }
public async isWorkerRegistered(workerInfo: DocWorkerInfo): Promise<boolean> {
return Promise.resolve(true);
}
public async releaseAssignment(workerId: string, docId: string): Promise<void> { public async releaseAssignment(workerId: string, docId: string): Promise<void> {
// nothing to do // nothing to do
} }
@ -241,7 +248,7 @@ export class DocWorkerMap implements IDocWorkerMap {
try { try {
// Drop out of available set first. // Drop out of available set first.
await this._client.sremAsync('workers-available', workerId); 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); await this._client.sremAsync(`workers-available-${group}`, workerId);
// At this point, this worker should no longer be receiving new doc assignments, though // At this point, this worker should no longer be receiving new doc assignments, though
// clients may still be directed to the worker. // 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<void> { public async setWorkerAvailability(workerId: string, available: boolean): Promise<void> {
log.info(`DocWorkerMap.setWorkerAvailability ${workerId} ${available}`); 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) { if (available) {
const docWorker = await this._client.hgetallAsync(`worker-${workerId}`) as DocWorkerInfo|null; const docWorker = await this._client.hgetallAsync(`worker-${workerId}`) as DocWorkerInfo|null;
if (!docWorker) { throw new Error('no doc worker contact info available'); } 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<boolean> {
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<void> { public async releaseAssignment(workerId: string, docId: string): Promise<void> {
const op = this._client.multi(); const op = this._client.multi();
op.del(`doc-${docId}`); op.del(`doc-${docId}`);
@ -352,7 +364,7 @@ export class DocWorkerMap implements IDocWorkerMap {
if (docId === 'import') { if (docId === 'import') {
const lock = await this._redlock.lock(`workers-lock`, LOCK_TIMEOUT); const lock = await this._redlock.lock(`workers-lock`, LOCK_TIMEOUT);
try { 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'); } if (!_workerId) { throw new Error('no doc worker available'); }
const docWorker = await this._client.hgetallAsync(`worker-${_workerId}`) as DocWorkerInfo|null; const docWorker = await this._client.hgetallAsync(`worker-${_workerId}`) as DocWorkerInfo|null;
if (!docWorker) { throw new Error('no doc worker contact info available'); } if (!docWorker) { throw new Error('no doc worker contact info available'); }
@ -383,7 +395,7 @@ export class DocWorkerMap implements IDocWorkerMap {
if (!workerId) { if (!workerId) {
// Check if document has a preferred worker group set. // 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. // Let's start off by assigning documents to available workers randomly.
// TODO: use a smarter algorithm. // TODO: use a smarter algorithm.

@ -392,7 +392,7 @@ export class DocWorkerApi {
const tablesTable = activeDoc.docData!.getMetaTable("_grist_Tables"); const tablesTable = activeDoc.docData!.getMetaTable("_grist_Tables");
const trigger = webhookId ? activeDoc.triggers.getWebhookTriggerRecord(webhookId) : undefined; const trigger = webhookId ? activeDoc.triggers.getWebhookTriggerRecord(webhookId) : undefined;
let currentTableId = trigger ? tablesTable.getValue(trigger.tableRef, 'tableId')! : 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 tableId = await getRealTableId(req.params.tableId || webhook.tableId, {metaTables});
const fields: Partial<SchemaTypes['_grist_Triggers']> = {}; const fields: Partial<SchemaTypes['_grist_Triggers']> = {};
@ -409,6 +409,23 @@ export class DocWorkerApi {
} }
if (tableId !== undefined) { 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); fields.tableRef = tableIdToRef(metaTables, tableId);
currentTableId = tableId; currentTableId = tableId;
} }
@ -910,7 +927,6 @@ export class DocWorkerApi {
const docId = activeDoc.docName; const docId = activeDoc.docName;
const webhookId = req.params.webhookId; const webhookId = req.params.webhookId;
const {fields, url} = await getWebhookSettings(activeDoc, req, webhookId, req.body); const {fields, url} = await getWebhookSettings(activeDoc, req, webhookId, req.body);
if (fields.enabled === false) { if (fields.enabled === false) {
await activeDoc.triggers.clearSingleWebhookQueue(webhookId); await activeDoc.triggers.clearSingleWebhookQueue(webhookId);
} }

@ -6,7 +6,7 @@
import { IChecksumStore } from 'app/server/lib/IChecksumStore'; import { IChecksumStore } from 'app/server/lib/IChecksumStore';
import { IElectionStore } from 'app/server/lib/IElectionStore'; import { IElectionStore } from 'app/server/lib/IElectionStore';
import { IPermitStores } from 'app/server/lib/Permit'; import { IPermitStores } from 'app/server/lib/Permit';
import {RedisClient} from 'redis'; import { RedisClient } from 'redis';
export interface DocWorkerInfo { export interface DocWorkerInfo {
id: string; id: string;
@ -57,6 +57,8 @@ export interface IDocWorkerMap extends IPermitStores, IElectionStore, IChecksumS
// release existing assignments. // release existing assignments.
setWorkerAvailability(workerId: string, available: boolean): Promise<void>; setWorkerAvailability(workerId: string, available: boolean): Promise<void>;
isWorkerRegistered(workerInfo: DocWorkerInfo): Promise<boolean>;
// Releases doc from worker, freeing it to be assigned elsewhere. // Releases doc from worker, freeing it to be assigned elsewhere.
// Assignments should only be released for workers that are now unavailable. // Assignments should only be released for workers that are now unavailable.
releaseAssignment(workerId: string, docId: string): Promise<void>; releaseAssignment(workerId: string, docId: string): Promise<void>;

@ -452,7 +452,8 @@ export class FlexServer implements GristServer {
// /status/hooks allows the tests to wait for them to be ready. // /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 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 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) => { this.app.get('/status(/hooks)?', async (req, res) => {
const checks = new Map<string, Promise<boolean>|boolean>(); const checks = new Map<string, Promise<boolean>|boolean>();
const timeout = optIntegerParam(req.query.timeout, 'timeout') || 10_000; const timeout = optIntegerParam(req.query.timeout, 'timeout') || 10_000;
@ -474,6 +475,20 @@ export class FlexServer implements GristServer {
if (isParameterOn(req.query.redis)) { if (isParameterOn(req.query.redis)) {
checks.set('redis', asyncCheck(this._docWorkerMap.getRedisClient()?.pingAsync())); 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)) { if (isParameterOn(req.query.ready)) {
checks.set('ready', this._isReady); checks.set('ready', this._isReady);
} }

@ -277,6 +277,7 @@ export class DocTriggers {
// Webhook might have been deleted in the mean time. // Webhook might have been deleted in the mean time.
continue; continue;
} }
const decodedWatchedColRefList = decodeObject(t.watchedColRefList) as number[] || [];
// Report some basic info and usage stats. // Report some basic info and usage stats.
const entry: WebhookSummary = { const entry: WebhookSummary = {
// Id of the webhook // Id of the webhook
@ -288,6 +289,7 @@ export class DocTriggers {
// Other fields used to register this webhook. // Other fields used to register this webhook.
eventTypes: decodeObject(t.eventTypes) as string[], eventTypes: decodeObject(t.eventTypes) as string[],
isReadyColumn: getColId(t.isReadyColRef) ?? null, isReadyColumn: getColId(t.isReadyColRef) ?? null,
watchedColIds: decodedWatchedColRefList.map((columnRef) => getColId(columnRef)),
tableId: getTableId(t.tableRef) ?? null, tableId: getTableId(t.tableRef) ?? null,
// For future use - for now every webhook is enabled. // For future use - for now every webhook is enabled.
enabled: t.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, // TODO: would be worth checking that the trigger's fields are valid (ie: eventTypes, url,
// ...) as there's no guarantee that they are. // ...) as there's no guarantee that they are.
@ -585,9 +602,21 @@ export class DocTriggers {
} }
} }
const colIdsToCheck: Array<string> = [];
if (trigger.watchedColRefList) {
for (const colRef of trigger.watchedColRefList.slice(1)) {
colIdsToCheck.push(this._getColId(colRef as number)!);
}
}
let eventType: EventType; let eventType: EventType;
if (readyBefore) { 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 we allow subscribing to deletion in the future
// if (recordDelta.existedAfter) { // if (recordDelta.existedAfter) {
// eventType = "update"; // eventType = "update";

@ -6,7 +6,7 @@ export const GRIST_DOC_SQL = `
PRAGMA foreign_keys=OFF; PRAGMA foreign_keys=OFF;
BEGIN TRANSACTION; 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 ''); 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" (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_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); 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_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_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_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 ''); 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,'',''); 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 ''); 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; PRAGMA foreign_keys=OFF;
BEGIN TRANSACTION; 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 ''); 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" (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); 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); 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_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_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_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 ''); 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,'',''); 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 ''); CREATE TABLE IF NOT EXISTS "_grist_ACLResources" (id INTEGER PRIMARY KEY, "tableId" TEXT DEFAULT '', "colIds" TEXT DEFAULT '');

@ -103,6 +103,12 @@ export function allowHost(req: IncomingMessage, allowedHost: string|URL) {
const proto = getEndUserProtocol(req); const proto = getEndUserProtocol(req);
const actualUrl = new URL(getOriginUrl(req)); const actualUrl = new URL(getOriginUrl(req));
const allowedUrl = (typeof allowedHost === 'string') ? new URL(`${proto}://${allowedHost}`) : allowedHost; 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) { if ((req as RequestWithOrg).isCustomHost) {
// For a request to a custom domain, the full hostname must match. // For a request to a custom domain, the full hostname must match.
return actualUrl.hostname === allowedUrl.hostname; return actualUrl.hostname === allowedUrl.hostname;

@ -409,6 +409,12 @@ export async function fetchDoc(server: GristServer, docId: string, req: Request,
// Prepare headers that preserve credentials of current user. // Prepare headers that preserve credentials of current user.
const headers = getTransitiveHeaders(req); 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. // Find the doc worker responsible for the document we wish to copy.
// The backend needs to be well configured for this to work. // The backend needs to be well configured for this to work.
const homeUrl = server.getHomeUrl(req); const homeUrl = server.getHomeUrl(req);

@ -56,6 +56,7 @@ module.exports = {
], ],
fallback: { fallback: {
'path': require.resolve("path-browserify"), 'path': require.resolve("path-browserify"),
'process': require.resolve("process/browser"),
}, },
}, },
module: { module: {
@ -79,7 +80,7 @@ module.exports = {
plugins: [ plugins: [
// Some modules assume presence of Buffer and process. // Some modules assume presence of Buffer and process.
new ProvidePlugin({ new ProvidePlugin({
process: 'process/browser', process: 'process',
Buffer: ['buffer', 'Buffer'] Buffer: ['buffer', 'Buffer']
}), }),
// To strip all locales except “en” // To strip all locales except “en”

@ -13,12 +13,13 @@
"install:python3": "buildtools/prepare_python3.sh", "install:python3": "buildtools/prepare_python3.sh",
"build:prod": "buildtools/build.sh", "build:prod": "buildtools/build.sh",
"start:prod": "sandbox/run.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: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: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: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: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:docker": "./test/test_under_docker.sh",
"test:python": "sandbox_venv3/bin/python sandbox/grist/runtests.py ${GREP_TESTS:+discover -p \"test*${GREP_TESTS}*.py\"}", "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", "cli": "NODE_PATH=_build:_build/stubs:_build/ext node _build/app/server/companion.js",
@ -76,7 +77,7 @@
"@types/redlock": "3.0.2", "@types/redlock": "3.0.2",
"@types/saml2-js": "2.0.1", "@types/saml2-js": "2.0.1",
"@types/selenium-webdriver": "4.1.15", "@types/selenium-webdriver": "4.1.15",
"@types/sinon": "5.0.5", "@types/sinon": "17.0.3",
"@types/sqlite3": "3.1.6", "@types/sqlite3": "3.1.6",
"@types/swagger-ui": "3.52.4", "@types/swagger-ui": "3.52.4",
"@types/tmp": "0.0.33", "@types/tmp": "0.0.33",
@ -100,7 +101,7 @@
"nodemon": "^2.0.4", "nodemon": "^2.0.4",
"otplib": "12.0.1", "otplib": "12.0.1",
"proper-lockfile": "4.1.2", "proper-lockfile": "4.1.2",
"sinon": "7.1.1", "sinon": "17.0.1",
"source-map-loader": "^0.2.4", "source-map-loader": "^0.2.4",
"tmp-promise": "1.0.5", "tmp-promise": "1.0.5",
"ts-interface-builder": "0.3.2", "ts-interface-builder": "0.3.2",

@ -1307,3 +1307,13 @@ def migration41(tdset):
] ]
return tdset.apply_doc_actions(doc_actions) 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'),
])

@ -15,7 +15,7 @@ import six
import actions import actions
SCHEMA_VERSION = 41 SCHEMA_VERSION = 42
def make_column(col_id, col_type, formula='', isFormula=False): def make_column(col_id, col_type, formula='', isFormula=False):
return { return {
@ -261,6 +261,8 @@ def schema_create_actions():
make_column("label", "Text"), make_column("label", "Text"),
make_column("memo", "Text"), make_column("memo", "Text"),
make_column("enabled", "Bool"), make_column("enabled", "Bool"),
make_column("watchedColRefList", "RefList:_grist_Tables_column"),
make_column("options", "Text"),
]), ]),
# All of the ACL rules. # All of the ACL rules.

@ -233,7 +233,8 @@
"Compare to Previous": "Mit vorherigem vergleichen", "Compare to Previous": "Mit vorherigem vergleichen",
"Open Snapshot": "Schnappschuss öffnen", "Open Snapshot": "Schnappschuss öffnen",
"Snapshots": "Schnappschüsse", "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": { "DocMenu": {
"(The organization needs a paid plan)": "(Die Organisation benötigt einen bezahlten Plan)", "(The organization needs a paid plan)": "(Die Organisation benötigt einen bezahlten Plan)",
@ -700,7 +701,9 @@
"Default field value": "Standard-Feldwert", "Default field value": "Standard-Feldwert",
"Field title": "Feldtitel", "Field title": "Feldtitel",
"Hidden field": "Verborgenes Feld", "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": { "RowContextMenu": {
"Copy anchor link": "Ankerlink kopieren", "Copy anchor link": "Ankerlink kopieren",
@ -712,7 +715,8 @@
"Insert row below": "Zeile unten einfügen", "Insert row below": "Zeile unten einfügen",
"Duplicate rows_one": "Zeile duplizieren", "Duplicate rows_one": "Zeile duplizieren",
"Duplicate rows_other": "Zeilen 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": { "SelectionSummary": {
"Copied to clipboard": "In die Zwischenablage kopiert" "Copied to clipboard": "In die Zwischenablage kopiert"
@ -738,7 +742,12 @@
"Unsaved": "Ungespeichert", "Unsaved": "Ungespeichert",
"Work on a Copy": "Arbeiten an einer Kopie", "Work on a Copy": "Arbeiten an einer Kopie",
"Share": "Teilen", "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": { "SiteSwitcher": {
"Create new team site": "Neue Teamseite erstellen", "Create new team site": "Neue Teamseite erstellen",
@ -1279,7 +1288,8 @@
"Help Center": "Hilfe-Center", "Help Center": "Hilfe-Center",
"Opt in to Telemetry": "Melden Sie sich für Telemetrie an", "Opt in to Telemetry": "Melden Sie sich für Telemetrie an",
"Opted In": "Angemeldet", "Opted In": "Angemeldet",
"Support Grist page": "Support Grist-Seite" "Support Grist page": "Support Grist-Seite",
"Admin Panel": "Verwaltungsbereich"
}, },
"SupportGristPage": { "SupportGristPage": {
"GitHub Sponsors page": "GitHub-Sponsorenseite", "GitHub Sponsors page": "GitHub-Sponsorenseite",
@ -1297,7 +1307,8 @@
"Support Grist": "Grist Support", "Support Grist": "Grist Support",
"Telemetry": "Telemetrie", "Telemetry": "Telemetrie",
"You have opted in to telemetry. Thank you!": "Sie haben sich für die Telemetrie entschieden. Vielen Dank!", "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": { "buildViewSectionDom": {
"No row selected in {{title}}": "Keine Zeile in {{title}} ausgewählt", "No row selected in {{title}}": "Keine Zeile in {{title}} ausgewählt",
@ -1381,7 +1392,21 @@
"Publish your form?": "Ihr Formular veröffentlichen?", "Publish your form?": "Ihr Formular veröffentlichen?",
"Unpublish": "Unveröffentlichen", "Unpublish": "Unveröffentlichen",
"Unpublish your form?": "Ihr Formular 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": { "Editor": {
"Delete": "Löschen" "Delete": "Löschen"
@ -1451,5 +1476,29 @@
"This week": "Diese Woche", "This week": "Diese Woche",
"This year": "Dieses Jahr", "This year": "Dieses Jahr",
"Today": "Heute" "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"
} }
} }

@ -225,7 +225,8 @@
"Compare to Previous": "Compare to Previous", "Compare to Previous": "Compare to Previous",
"Open Snapshot": "Open Snapshot", "Open Snapshot": "Open Snapshot",
"Snapshots": "Snapshots", "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": { "DocMenu": {
"(The organization needs a paid plan)": "(The organization needs a paid plan)", "(The organization needs a paid plan)": "(The organization needs a paid plan)",
@ -480,7 +481,8 @@
"Welcome to {{- orgName}}": "Welcome to {{- orgName}}", "Welcome to {{- orgName}}": "Welcome to {{- orgName}}",
"Sign in": "Sign in", "Sign in": "Sign in",
"To use Grist, please either sign up or sign in.": "To use Grist, please either sign up or 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": { "HomeLeftPane": {
"Access Details": "Access Details", "Access Details": "Access Details",
@ -1168,7 +1170,21 @@
}, },
"WebhookPage": { "WebhookPage": {
"Clear Queue": "Clear Queue", "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": { "FormulaAssistant": {
"Ask the bot.": "Ask the bot.", "Ask the bot.": "Ask the bot.",
@ -1278,7 +1294,9 @@
"Opt in to Telemetry": "Opt in to Telemetry", "Opt in to Telemetry": "Opt in to Telemetry",
"Opted In": "Opted In", "Opted In": "Opted In",
"Support Grist": "Support Grist", "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": { "SupportGristPage": {
"GitHub": "GitHub", "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.", "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 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 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": { "buildViewSectionDom": {
"No data": "No data", "No data": "No data",
@ -1421,5 +1440,31 @@
"Section": { "Section": {
"Insert section above": "Insert section above", "Insert section above": "Insert section above",
"Insert section below": "Insert section below" "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"
} }
} }

@ -155,7 +155,7 @@
"Others": "Otros", "Others": "Otros",
"Search": "Buscar", "Search": "Buscar",
"Search values": "Buscar valores", "Search values": "Buscar valores",
"Start": "Inicio", "Start": "Iniciar",
"Filter by Range": "Filtrar por rango" "Filter by Range": "Filtrar por rango"
}, },
"CustomSectionConfig": { "CustomSectionConfig": {
@ -187,7 +187,8 @@
"Compare to Previous": "Comparar con el anterior", "Compare to Previous": "Comparar con el anterior",
"Open Snapshot": "Abrir instantánea", "Open Snapshot": "Abrir instantánea",
"Snapshots": "Instantáneas", "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": { "DocMenu": {
"(The organization needs a paid plan)": "(La organización necesita un plan de pago)", "(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", "Opt in to Telemetry": "Participar en Telemetría",
"Support Grist page": "Página de soporte de Grist", "Support Grist page": "Página de soporte de Grist",
"Close": "Cerrar", "Close": "Cerrar",
"Contribute": "Contribuir" "Contribute": "Contribuir",
"Admin Panel": "Panel de control"
}, },
"SupportGristPage": { "SupportGristPage": {
"GitHub": "GitHub", "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 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 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.", "You have opted out of telemetry.": "Ha optado por no participar en la telemetría.",
"Home": "Inicio" "Home": "Inicio",
"Sponsor": "Patrocinador"
}, },
"buildViewSectionDom": { "buildViewSectionDom": {
"No data": "Sin datos", "No data": "Sin datos",
@ -1475,5 +1478,17 @@
"Section": { "Section": {
"Insert section above": "Insertar la sección anterior", "Insert section above": "Insertar la sección anterior",
"Insert section below": "Insertar la sección siguiente" "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"
} }
} }

@ -221,7 +221,8 @@
"Compare to Current": "Comparer au document en cours", "Compare to Current": "Comparer au document en cours",
"Compare to Previous": "Comparer au précédent", "Compare to Previous": "Comparer au précédent",
"Beta": "Bêta", "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": { "DocMenu": {
"Other Sites": "Autres espaces", "Other Sites": "Autres espaces",
@ -652,7 +653,9 @@
"Enter redirect URL": "Saisir l'URL de redirection", "Enter redirect URL": "Saisir l'URL de redirection",
"Reset form": "Réinitialiser le formulaire", "Reset form": "Réinitialiser le formulaire",
"Submit another response": "Soumettre une autre réponse", "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": { "RowContextMenu": {
"Insert row": "Insérer une ligne", "Insert row": "Insérer une ligne",
@ -662,7 +665,8 @@
"Duplicate rows_other": "Dupliquer les lignes", "Duplicate rows_other": "Dupliquer les lignes",
"Delete": "Supprimer", "Delete": "Supprimer",
"Copy anchor link": "Copier l'ancre", "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": { "SelectionSummary": {
"Copied to clipboard": "Copié dans le presse-papier" "Copied to clipboard": "Copié dans le presse-papier"
@ -688,7 +692,9 @@
"Export XLSX": "Exporter en XLSX", "Export XLSX": "Exporter en XLSX",
"Send to Google Drive": "Envoyer vers Google Drive", "Send to Google Drive": "Envoyer vers Google Drive",
"Share": "Partager", "Share": "Partager",
"Download...": "Télécharger..." "Download...": "Télécharger...",
"Microsoft Excel (.xlsx)": "Microsoft Excel (.xlsx)",
"Export as...": "Exporter en tant que..."
}, },
"SiteSwitcher": { "SiteSwitcher": {
"Switch Sites": "Changer despace", "Switch Sites": "Changer despace",
@ -1209,7 +1215,8 @@
"Support Grist": "Support Grist", "Support Grist": "Support Grist",
"Opt in to Telemetry": "S'inscrire à l'envoi de données de télémétrie", "Opt in to Telemetry": "S'inscrire à l'envoi de données de télémétrie",
"Opted In": "Accepté", "Opted In": "Accepté",
"Support Grist page": "Soutenir Grist" "Support Grist page": "Soutenir Grist",
"Admin Panel": "Panneau d'administration"
}, },
"GridView": { "GridView": {
"Click to insert": "Cliquer pour insérer" "Click to insert": "Cliquer pour insérer"
@ -1218,8 +1225,8 @@
"GitHub": "GitHub", "GitHub": "GitHub",
"Help Center": "Centre d'aide", "Help Center": "Centre d'aide",
"Home": "Accueil", "Home": "Accueil",
"Sponsor Grist Labs on GitHub": "Sponsoriser Grist Labs sur GitHub", "Sponsor Grist Labs on GitHub": "Parrainer Grist Labs sur GitHub",
"GitHub Sponsors page": "Page de sponsors GitHub", "GitHub Sponsors page": "Page de parrainage GitHub",
"Manage Sponsorship": "Gérer le parrainage", "Manage Sponsorship": "Gérer le parrainage",
"Opt in to Telemetry": "S'inscrire à l'envoi de données de télémétrie", "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", "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.", "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.", "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 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": { "buildViewSectionDom": {
"No data": "Aucune donnée", "No data": "Aucune donnée",
@ -1323,7 +1331,21 @@
"Publish": "Publier", "Publish": "Publier",
"Publish your form?": "Publier votre formulaire?", "Publish your form?": "Publier votre formulaire?",
"Unpublish": "Dépublier", "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": { "HiddenQuestionConfig": {
"Hidden fields": "Champs cachés" "Hidden fields": "Champs cachés"
@ -1389,5 +1411,29 @@
"This week": "Cette semaine", "This week": "Cette semaine",
"This year": "Cette année", "This year": "Cette année",
"Today": "Aujourd'hui" "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"
} }
} }

@ -63,7 +63,8 @@
"Compare to Current": "Comparar ao atual", "Compare to Current": "Comparar ao atual",
"Compare to Previous": "Comparar ao anterior", "Compare to Previous": "Comparar ao anterior",
"Open Snapshot": "Abrir Instantâneo", "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": { "DocMenu": {
"By Date Modified": "Por Data de Modificação", "By Date Modified": "Por Data de Modificação",
@ -181,7 +182,9 @@
"Enter document name": "Digite o nome do documento", "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 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)", "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": { "Pages": {
"Delete": "Apagar", "Delete": "Apagar",
@ -219,7 +222,27 @@
"WIDGET TITLE": "TÍTULO DO WIDGET", "WIDGET TITLE": "TÍTULO DO WIDGET",
"Widget": "Widget", "Widget": "Widget",
"You do not have edit access to this document": "Não tem permissão de edição desse documento", "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": { "ShareMenu": {
"Return to {{termToUse}}": "Retornar ao {{termToUse}}", "Return to {{termToUse}}": "Retornar ao {{termToUse}}",
@ -242,7 +265,12 @@
"Original": "Original", "Original": "Original",
"Replace {{termToUse}}...": "Substituir {{termToUse}}…", "Replace {{termToUse}}...": "Substituir {{termToUse}}…",
"Share": "Partilhar", "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": { "SiteSwitcher": {
"Create new team site": "Criar site de equipa", "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.", "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", "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. ", "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": { "ChartView": {
"Toggle chart aggregation": "Alternar a agregação de gráficos", "Toggle chart aggregation": "Alternar a agregação de gráficos",
@ -390,7 +419,8 @@
"Insert row": "Inserir linha", "Insert row": "Inserir linha",
"Insert row above": "Inserir linha acima", "Insert row above": "Inserir linha acima",
"Insert row below": "Inserir linha abaixo", "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": { "SelectionSummary": {
"Copied to clipboard": "Copiado para a área de transferência" "Copied to clipboard": "Copiado para a área de transferência"
@ -426,7 +456,8 @@
"TOOLS": "FERRAMENTAS", "TOOLS": "FERRAMENTAS",
"Tour of this Document": "Tour desse Documento", "Tour of this Document": "Tour desse Documento",
"Validate Data": "Validar dados", "Validate Data": "Validar dados",
"Settings": "Configurações" "Settings": "Configurações",
"API Console": "Consola API"
}, },
"TopBar": { "TopBar": {
"Manage Team": "Gerir Equipa" "Manage Team": "Gerir Equipa"
@ -574,7 +605,19 @@
"Adding UUID column": "A adicionar coluna UUID", "Adding UUID column": "A adicionar coluna UUID",
"Adding duplicates column": "Adicionar coluna duplicatas", "Adding duplicates column": "Adicionar coluna duplicatas",
"Detect duplicates in...": "Detetar duplicados em...", "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": { "HomeIntro": {
"Any documents created in this site will appear here.": "Qualquer documento criado neste site aparecerá aqui.", "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.", "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.", "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.", "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": { "WelcomeQuestions": {
"IT & Technology": "TI e Tecnologia", "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.", "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}}", "Account deleted{{suffix}}": "Conta excluída{{suffix}}",
"Your account has been deleted.": "A sua conta foi apagada.", "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": { "CellStyle": {
"Cell Style": "Estilo de célula", "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", "Document ID copied to clipboard": "ID do documento copiado para a área de transferência",
"Ok": "OK", "Ok": "OK",
"Manage Webhooks": "Gerir ganchos web", "Manage Webhooks": "Gerir ganchos web",
"Webhooks": "Ganchos Web" "Webhooks": "Ganchos Web",
"API Console": "Consola API"
}, },
"DocumentUsage": { "DocumentUsage": {
"Attachments Size": "Tamanho dos Anexos", "Attachments Size": "Tamanho dos Anexos",
@ -1019,7 +1071,8 @@
"Show raw data": "Mostrar dados primários", "Show raw data": "Mostrar dados primários",
"Widget options": "Opções do Widget", "Widget options": "Opções do Widget",
"Add to page": "Adicionar à página", "Add to page": "Adicionar à página",
"Collapse widget": "Colapsar widget" "Collapse widget": "Colapsar widget",
"Create a form": "Criar um formulário"
}, },
"ViewSectionMenu": { "ViewSectionMenu": {
"(customized)": "(personalizado)", "(customized)": "(personalizado)",
@ -1063,7 +1116,17 @@
"modals": { "modals": {
"Cancel": "Cancelar", "Cancel": "Cancelar",
"Ok": "OK", "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": { "pages": {
"Duplicate Page": "Duplicar a Página", "Duplicate Page": "Duplicar a Página",
@ -1216,7 +1279,8 @@
"Opt in to Telemetry": "Aceitar a Telemetria", "Opt in to Telemetry": "Aceitar a Telemetria",
"Opted In": "Optou por participar", "Opted In": "Optou por participar",
"Support Grist": "Suporte Grist", "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": { "SupportGristPage": {
"GitHub": "GitHub", "GitHub": "GitHub",
@ -1234,7 +1298,8 @@
"You have opted out of telemetry.": "Decidiu em não participar da telemetria.", "You have opted out of telemetry.": "Decidiu em não participar da telemetria.",
"Support Grist": "Suporte Grist", "Support Grist": "Suporte Grist",
"Telemetry": "Telemetria", "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": { "buildViewSectionDom": {
"No data": "Sem dados", "No data": "Sem dados",
@ -1255,5 +1320,121 @@
"Delete card": "Apagar cartão", "Delete card": "Apagar cartão",
"Copy anchor link": "Copiar ligação de ancoragem", "Copy anchor link": "Copiar ligação de ancoragem",
"Insert card": "Inserir cartão" "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"
} }
} }

@ -233,7 +233,8 @@
"Compare to Previous": "Comparar ao anterior", "Compare to Previous": "Comparar ao anterior",
"Open Snapshot": "Abrir Instantâneo", "Open Snapshot": "Abrir Instantâneo",
"Snapshots": "Instantâneos", "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": { "DocMenu": {
"(The organization needs a paid plan)": "(A organização precisa de um plano pago)", "(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", "Redirect automatically after submission": "Redirecionar automaticamente após o envio",
"Configuration": "Configuração", "Configuration": "Configuração",
"Success text": "Texto de sucesso", "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": { "RowContextMenu": {
"Copy anchor link": "Copiar o link de ancoragem", "Copy anchor link": "Copiar o link de ancoragem",
@ -712,7 +715,8 @@
"Insert row below": "Inserir linha abaixo", "Insert row below": "Inserir linha abaixo",
"Duplicate rows_one": "Duplicar linha", "Duplicate rows_one": "Duplicar linha",
"Duplicate rows_other": "Duplicar linhas", "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": { "SelectionSummary": {
"Copied to clipboard": "Copiado para a área de transferência" "Copied to clipboard": "Copiado para a área de transferência"
@ -738,7 +742,12 @@
"Unsaved": "Não Salvo", "Unsaved": "Não Salvo",
"Work on a Copy": "Trabalho em uma cópia", "Work on a Copy": "Trabalho em uma cópia",
"Share": "Compartilhar", "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": { "SiteSwitcher": {
"Create new team site": "Criar novo site de equipe", "Create new team site": "Criar novo site de equipe",
@ -1279,7 +1288,8 @@
"Support Grist": "Suporte Grist", "Support Grist": "Suporte Grist",
"Contribute": "Contribuir", "Contribute": "Contribuir",
"Opted In": "Optou por participar", "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": { "SupportGristPage": {
"GitHub": "GitHub", "GitHub": "GitHub",
@ -1297,7 +1307,8 @@
"You have opted out of telemetry.": "Você decidiu em não participar da telemetria.", "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.", "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.", "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": { "buildViewSectionDom": {
"No data": "Sem dados", "No data": "Sem dados",
@ -1381,7 +1392,21 @@
"Publish": "Publicar", "Publish": "Publicar",
"Publish your form?": "Publicar o seu formulário?", "Publish your form?": "Publicar o seu formulário?",
"Unpublish your form?": "Despublicar 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": { "Menu": {
"Columns": "Colunas", "Columns": "Colunas",
@ -1451,5 +1476,29 @@
"This week": "Esta semana", "This week": "Esta semana",
"This year": "Este ano", "This year": "Este ano",
"Today": "Hoje" "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"
} }
} }

@ -358,7 +358,8 @@
"Beta": "Beta", "Beta": "Beta",
"Compare to Current": "Сравните с текущим", "Compare to Current": "Сравните с текущим",
"Snapshots are unavailable.": "Снимки недоступны.", "Snapshots are unavailable.": "Снимки недоступны.",
"Open Snapshot": "Открыть Снимок" "Open Snapshot": "Открыть Снимок",
"Only owners have access to snapshots for documents with access rules.": "Только владельцы имеют доступ к снимкам документов с правилами доступа."
}, },
"DocMenu": { "DocMenu": {
"By Name": "По имени", "By Name": "По имени",
@ -1268,7 +1269,8 @@
"Support Grist": "Поддержка Grist", "Support Grist": "Поддержка Grist",
"Support Grist page": "Страница поддержки Grist", "Support Grist page": "Страница поддержки Grist",
"Contribute": "Участвовать", "Contribute": "Участвовать",
"Opted In": "Подключено" "Opted In": "Подключено",
"Admin Panel": "Панель администратора"
}, },
"SupportGristPage": { "SupportGristPage": {
"GitHub": "GitHub", "GitHub": "GitHub",
@ -1286,7 +1288,8 @@
"Opt out of Telemetry": "Отказаться от телеметрии", "Opt out of Telemetry": "Отказаться от телеметрии",
"Sponsor Grist Labs on GitHub": "Спонсор Grist Labs на GitHub", "Sponsor Grist Labs on GitHub": "Спонсор Grist Labs на GitHub",
"This instance is opted in to telemetry. Only the site administrator has permission to change this.": "В этом экземпляре включена телеметрия. Только администратор сайта может изменить это.", "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": { "buildViewSectionDom": {
"No data": "Нет данных", "No data": "Нет данных",
@ -1421,5 +1424,17 @@
"Section": { "Section": {
"Insert section above": "Вставить секцию выше", "Insert section above": "Вставить секцию выше",
"Insert section below": "Вставить секцию ниже" "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": "Телеметрия"
} }
} }

@ -412,7 +412,8 @@
"Compare to Previous": "Primerjava s prejšnjimi", "Compare to Previous": "Primerjava s prejšnjimi",
"Snapshots": "Posnetki", "Snapshots": "Posnetki",
"Snapshots are unavailable.": "Posnetki niso na voljo.", "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": { "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.", "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", "Opt in to Telemetry": "Prijava na telemetrijo",
"You have opted in to telemetry. Thank you!": "Prijavili ste se za telemetrijo. Zahvaljujemo se vam!", "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.", "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": { "GristTooltips": {
"Updates every 5 minutes.": "Posodablja se vsakih 5 minut.", "Updates every 5 minutes.": "Posodablja se vsakih 5 minut.",
@ -845,7 +847,8 @@
"Help Center": "Center za pomoč", "Help Center": "Center za pomoč",
"Contribute": "Prispevajte", "Contribute": "Prispevajte",
"Support Grist page": "Grist podpora", "Support Grist page": "Grist podpora",
"Opted In": "Prijavljeno" "Opted In": "Prijavljeno",
"Admin Panel": "Skrbniški panel"
}, },
"HomeIntro": { "HomeIntro": {
"personal site": "osebna stran", "personal site": "osebna stran",
@ -995,7 +998,21 @@
}, },
"WebhookPage": { "WebhookPage": {
"Clear Queue": "Počisti čakalno vrsto", "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": { "RecordLayout": {
"Updating record layout.": "Posodobitev postavitve zapisa." "Updating record layout.": "Posodobitev postavitve zapisa."
@ -1421,5 +1438,17 @@
"Section": { "Section": {
"Insert section above": "Vstavi razdelek zgoraj", "Insert section above": "Vstavi razdelek zgoraj",
"Insert section below": "Vstavite razdelek spodaj" "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"
} }
} }

File diff suppressed because it is too large Load Diff

@ -1 +1 @@
export * from 'app/client/ui/ProductUpgradesStub'; export * from 'app/client/ui/CreateTeamModal';

@ -28,6 +28,7 @@ declare module "redis" {
function createClient(url?: string): RedisClient; function createClient(url?: string): RedisClient;
class RedisClient { class RedisClient {
public readonly connected: boolean;
public eval(args: any[], callback?: (err: Error | null, res: any) => void): any; public eval(args: any[], callback?: (err: Error | null, res: any) => void): any;
public subscribe(channel: string): void; public subscribe(channel: string): void;

@ -46,7 +46,7 @@ describe('SafeBrowser', function() {
browserProcesses = []; browserProcesses = [];
sandbox.stub(SafeBrowser, 'createWorker').callsFake(createProcess); 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); sandbox.stub(PluginInstance.prototype, 'getRenderTarget').returns(noop);
disposeSpy = sandbox.spy(Disposable.prototype, 'dispose'); disposeSpy = sandbox.spy(Disposable.prototype, 'dispose');
}); });

@ -153,9 +153,8 @@ describe('dispose', function() {
assert.equal(baz.dispose.callCount, 1); assert.equal(baz.dispose.callCount, 1);
assert(baz.dispose.calledBefore(bar.dispose)); assert(baz.dispose.calledBefore(bar.dispose));
const name = consoleErrors[0][1]; // may be Foo, or minified. const name = consoleErrors[0][1];
assert(name === 'Foo' || name === 'o'); // this may not be reliable, assert(name === Foo.name);
// just what I happen to see.
assert.deepEqual(consoleErrors[0], ['Error constructing %s:', name, 'Error: test-error1']); 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[1], ['Error constructing %s:', name, 'Error: test-error2']);
assert.deepEqual(consoleErrors[2], ['Error constructing %s:', name, 'Error: test-error3']); assert.deepEqual(consoleErrors[2], ['Error constructing %s:', name, 'Error: test-error3']);

@ -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);
});
});
});
});

@ -8,6 +8,18 @@ import fs from "fs";
import os from "os"; import os from "os";
import path from 'path'; 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() { describe("Localization", function() {
this.timeout(60000); this.timeout(60000);
setupTestSuite(); setupTestSuite();
@ -40,20 +52,20 @@ describe("Localization", function() {
const langs: Set<string> = new Set(); const langs: Set<string> = new Set();
const namespaces: Set<string> = new Set(); const namespaces: Set<string> = new Set();
for (const file of fs.readdirSync(localeDirectory)) { for (const file of fs.readdirSync(localeDirectory)) {
if (file.endsWith(".json")) { // Make sure we see only valid files.
const langRaw = file.split('.')[0]; assert.match(file, VALID_LOCALE_FORMAT);
const lang = langRaw?.replace(/_/g, '-'); const langRaw = file.split('.')[0];
const ns = file.split('.')[1]; const lang = langRaw?.replace(/_/g, '-');
const clientFile = path.join(localeDirectory, const ns = file.split('.')[1];
`${langRaw}.client.json`); const clientFile = path.join(localeDirectory,
const clientText = fs.readFileSync(clientFile, { encoding: 'utf8' }); `${langRaw}.client.json`);
if (!clientText.includes('Translators: please translate this only when')) { const clientText = fs.readFileSync(clientFile, { encoding: 'utf8' });
// Translation not ready if this key is not present. if (!clientText.includes('Translators: please translate this only when')) {
continue; // Translation not ready if this key is not present.
} continue;
langs.add(lang);
namespaces.add(ns);
} }
langs.add(lang);
namespaces.add(ns);
} }
assert.deepEqual(gristConfig.supportedLngs.sort(), [...langs].sort()); assert.deepEqual(gristConfig.supportedLngs.sort(), [...langs].sort());
assert.deepEqual(gristConfig.namespaces.sort(), [...namespaces].sort()); assert.deepEqual(gristConfig.namespaces.sort(), [...namespaces].sort());
@ -90,6 +102,8 @@ describe("Localization", function() {
const enResponse = await (await fetch(homeUrl)).text(); const enResponse = await (await fetch(homeUrl)).text();
const uzResponse = await (await fetch(homeUrl, {headers: {"Accept-Language": "uz-UZ,uz;q=1"}})).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(); 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[]) { function present(response: string, ...langs: string[]) {
for (const lang of langs) { for (const lang of langs) {
@ -107,6 +121,7 @@ describe("Localization", function() {
present(enResponse, "en"); present(enResponse, "en");
present(uzResponse, "en"); present(uzResponse, "en");
present(ptResponse, "en"); present(ptResponse, "en");
present(noResponse, "en");
// Other locales are not preloaded for English. // Other locales are not preloaded for English.
notPresent(enResponse, "uz", "un-UZ", "en-US"); notPresent(enResponse, "uz", "un-UZ", "en-US");
@ -117,6 +132,9 @@ describe("Localization", function() {
notPresent(uzResponse, "uz-UZ"); notPresent(uzResponse, "uz-UZ");
notPresent(ptResponse, "pt-PR", "uz", "en-US"); 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() { it("loads correct languages from file system", async function() {

@ -34,6 +34,7 @@ describe('WebhookOverflow', function () {
enabled: true, enabled: true,
name: 'test webhook', name: 'test webhook',
tableId: 'Table2', tableId: 'Table2',
watchedColIds: []
}; };
await docApi.addWebhook(webhookDetails); await docApi.addWebhook(webhookDetails);
await docApi.addWebhook(webhookDetails); await docApi.addWebhook(webhookDetails);

@ -55,6 +55,7 @@ describe('WebhookPage', function () {
'URL', 'URL',
'Table', 'Table',
'Ready Column', 'Ready Column',
'Filter for changes in these columns (semicolon-separated ids)',
'Webhook Id', 'Webhook Id',
'Enabled', 'Enabled',
'Status', 'Status',
@ -80,15 +81,17 @@ describe('WebhookPage', function () {
await gu.waitToPass(async () => { await gu.waitToPass(async () => {
assert.equal(await getField(1, 'Webhook Id'), id); 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, 'Name', 'Test Webhook');
await setField(1, 'Memo', 'Test Memo'); await setField(1, 'Memo', 'Test Memo');
await setField(1, 'Filter for changes in these columns (semicolon-separated ids)', 'A; B');
await gu.waitForServer(); await gu.waitForServer();
await driver.navigate().refresh(); await driver.navigate().refresh();
await waitForWebhookPage(); await waitForWebhookPage();
await gu.waitToPass(async () => { await gu.waitToPass(async () => {
assert.equal(await getField(1, 'Name'), 'Test Webhook'); assert.equal(await getField(1, 'Name'), 'Test Webhook');
assert.equal(await getField(1, 'Memo'), 'Test Memo'); 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. // Make sure the webhook is actually working.
await docApi.addRows('Table1', {A: ['zig'], B: ['zag']}); await docApi.addRows('Table1', {A: ['zig'], B: ['zag']});

@ -67,6 +67,9 @@ export class TestServerMerged extends EventEmitter implements IMochaServer {
this._starts++; this._starts++;
const workerIdText = process.env.MOCHA_WORKER_ID || '0'; const workerIdText = process.env.MOCHA_WORKER_ID || '0';
if (reset) { 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) { if (process.env.TESTDIR) {
this.testDir = path.join(process.env.TESTDIR, workerIdText); this.testDir = path.join(process.env.TESTDIR, workerIdText);
} else { } else {

@ -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');
});
});

@ -402,20 +402,19 @@ describe('Comm', function() {
// Intercept the call to _onClose to know when it occurs, since we are trying to hit a // 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. // situation where 'close' and 'failedSend' events happen in either order.
const stubOnClose = sandbox.stub(Client.prototype as any, '_onClose') const stubOnClose: any = sandbox.stub(Client.prototype as any, '_onClose')
.callsFake(async function(this: Client) { .callsFake(function(this: Client) {
if (!options.closeHappensFirst) { await delay(10); }
eventsSeen.push('close'); 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 // 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 // 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. // 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) { .callsFake(async function(this: Client) {
try { try {
return await (stubSendToWebsocket as any).wrappedMethod.apply(this, arguments); return await stubSendToWebsocket.wrappedMethod.apply(this, arguments);
} catch (err) { } catch (err) {
if (options.closeHappensFirst) { await delay(100); } if (options.closeHappensFirst) { await delay(100); }
eventsSeen.push('failedSend'); eventsSeen.push('failedSend');

@ -3347,13 +3347,21 @@ function testDocApi() {
}); });
describe('webhooks related endpoints', async function () { describe('webhooks related endpoints', async function () {
/* const serving: Serving = await serveSomething(app => {
Regression test for old _subscribe endpoint. /docs/{did}/webhooks should be used instead to subscribe app.use(express.json());
*/ app.post('/200', ({body}, res) => {
async function oldSubscribeCheck(requestBody: any, status: number, ...errors: RegExp[]) { res.sendStatus(200);
const resp = await axios.post( res.end();
`${serverUrl}/api/docs/${docIds.Timesheets}/tables/Table1/_subscribe`, });
requestBody, chimpy }, 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); assert.equal(resp.status, status);
for (const error of errors) { for (const error of errors) {
@ -3430,7 +3438,15 @@ function testDocApi() {
await postWebhookCheck({webhooks:[{fields: {eventTypes: ["add"], url: "https://example.com"}}]}, await postWebhookCheck({webhooks:[{fields: {eventTypes: ["add"], url: "https://example.com"}}]},
400, /tableId is missing/); 400, /tableId is missing/);
await postWebhookCheck({}, 400, /webhooks 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, tableId?: string,
isReadyColumn?: string | null, isReadyColumn?: string | null,
eventTypes?: string[] eventTypes?: string[]
watchedColIds?: string[],
}) { }) {
// Subscribe helper that returns a method to unsubscribe. // Subscribe helper that returns a method to unsubscribe.
const data = await subscribe(endpoint, docId, options); const data = await subscribe(endpoint, docId, options);
@ -3872,6 +3889,7 @@ function testDocApi() {
tableId?: string, tableId?: string,
isReadyColumn?: string|null, isReadyColumn?: string|null,
eventTypes?: string[], eventTypes?: string[],
watchedColIds?: string[],
name?: string, name?: string,
memo?: string, memo?: string,
enabled?: boolean, enabled?: boolean,
@ -3883,7 +3901,7 @@ function testDocApi() {
eventTypes: options?.eventTypes ?? ['add', 'update'], eventTypes: options?.eventTypes ?? ['add', 'update'],
url: `${serving.url}/${endpoint}`, url: `${serving.url}/${endpoint}`,
isReadyColumn: options?.isReadyColumn === undefined ? 'B' : options?.isReadyColumn, isReadyColumn: options?.isReadyColumn === undefined ? 'B' : options?.isReadyColumn,
...pick(options, 'name', 'memo', 'enabled'), ...pick(options, 'name', 'memo', 'enabled', 'watchedColIds'),
}, chimpy }, chimpy
); );
assert.equal(status, 200); assert.equal(status, 200);
@ -4407,6 +4425,72 @@ function testDocApi() {
await webhook1(); 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 () => { it("should return statistics", async () => {
await clearQueue(docId); await clearQueue(docId);
// Read stats, it should be empty. // Read stats, it should be empty.
@ -4427,6 +4511,7 @@ function testDocApi() {
tableId: 'Table1', tableId: 'Table1',
name: '', name: '',
memo: '', memo: '',
watchedColIds: [],
}, usage : { }, usage : {
status: 'idle', status: 'idle',
numWaiting: 0, numWaiting: 0,
@ -4444,6 +4529,7 @@ function testDocApi() {
tableId: 'Table1', tableId: 'Table1',
name: '', name: '',
memo: '', memo: '',
watchedColIds: [],
}, usage : { }, usage : {
status: 'idle', status: 'idle',
numWaiting: 0, numWaiting: 0,
@ -4775,42 +4861,53 @@ function testDocApi() {
describe('webhook update', function () { describe('webhook update', function () {
it('should work correctly', async function () { it('should work correctly', async function () {
async function check(fields: any, status: number, error?: RegExp | string, async function check(fields: any, status: number, error?: RegExp | string,
expectedFieldsCallback?: (fields: any) => any) { expectedFieldsCallback?: (fields: any) => any) {
let savedTableId = 'Table1';
const origFields = { const origFields = {
tableId: 'Table1', tableId: 'Table1',
eventTypes: ['add'], eventTypes: ['add'],
isReadyColumn: 'B', isReadyColumn: 'B',
name: 'My Webhook', name: 'My Webhook',
memo: 'Sync store', memo: 'Sync store',
watchedColIds: ['A']
}; };
// subscribe // 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 = { const expectedFields = {
url: `${serving.url}/foo`, url: `${serving.url}/foo`,
unsubscribeKey: webhook.unsubscribeKey,
eventTypes: ['add'], eventTypes: ['add'],
isReadyColumn: 'B', isReadyColumn: 'B',
tableId: 'Table1', tableId: 'Table1',
enabled: true, enabled: true,
name: 'My Webhook', name: 'My Webhook',
memo: 'Sync store', memo: 'Sync store',
watchedColIds: ['A'],
}; };
let stats = await readStats(docId); let stats = await readStats(docId);
assert.equal(stats.length, 1, 'stats=' + JSON.stringify(stats)); assert.equal(stats.length, 1, 'stats=' + JSON.stringify(stats));
assert.equal(stats[0].id, webhook.webhookId); assert.equal(stats[0].id, webhooks.webhooks[0].id);
assert.deepEqual(stats[0].fields, expectedFields); // eslint-disable-next-line @typescript-eslint/no-unused-vars
const {unsubscribeKey, ...fieldsWithoutUnsubscribeKey} = stats[0].fields;
assert.deepEqual(fieldsWithoutUnsubscribeKey, expectedFields);
// update // update
const resp = await axios.patch( 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 // check resp
@ -4818,14 +4915,13 @@ function testDocApi() {
if (resp.status === 200) { if (resp.status === 200) {
stats = await readStats(docId); stats = await readStats(docId);
assert.equal(stats.length, 1); assert.equal(stats.length, 1);
assert.equal(stats[0].id, webhook.webhookId); assert.equal(stats[0].id, webhooks.webhooks[0].id);
if (expectedFieldsCallback) { if (expectedFieldsCallback) {
expectedFieldsCallback(expectedFields); expectedFieldsCallback(expectedFields);
} }
assert.deepEqual(stats[0].fields, {...expectedFields, ...fields}); // eslint-disable-next-line @typescript-eslint/no-unused-vars
if (fields.tableId) { const {unsubscribeKey, ...fieldsWithoutUnsubscribeKey} = stats[0].fields;
savedTableId = fields.tableId; assert.deepEqual(fieldsWithoutUnsubscribeKey, { ...expectedFields, ...fields });
}
} else { } else {
if (error instanceof RegExp) { if (error instanceof RegExp) {
assert.match(resp.data.details?.userError || resp.data.error, error); assert.match(resp.data.details?.userError || resp.data.error, error);
@ -4835,7 +4931,9 @@ function testDocApi() {
} }
// finally unsubscribe // 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']))); assert.equal(unsubscribeResp.status, 200, JSON.stringify(pick(unsubscribeResp, ['data', 'status'])));
stats = await readStats(docId); stats = await readStats(docId);
assert.equal(stats.length, 0, 'stats=' + JSON.stringify(stats)); 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 await check({url: "http://example.com"}, 403, "Provided url is forbidden"); // not https
// changing table without changing the ready column should reset the latter // 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: '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: ['add', 'update']}, 200);
await check({eventTypes: []}, 400, "eventTypes must be a non-empty array"); await check({eventTypes: []}, 400, "eventTypes must be a non-empty array");

@ -48,7 +48,7 @@ describe("MinIOExternalStorage", function () {
s3.listObjects.returns(fakeStream); 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); const result = await extStorage.versions(key);
assert.deepEqual(result, []); assert.deepEqual(result, []);
@ -74,7 +74,7 @@ describe("MinIOExternalStorage", function () {
]); ]);
s3.listObjects.returns(fakeStream); s3.listObjects.returns(fakeStream);
const extStorage = new MinIOExternalStorage(dummyBucket, dummyOptions, 42, s3); const extStorage = new MinIOExternalStorage(dummyBucket, dummyOptions, 42, s3 as any);
// when // when
const result = await extStorage.versions(key); const result = await extStorage.versions(key);
// then // then
@ -107,7 +107,7 @@ describe("MinIOExternalStorage", function () {
let {fakeStream} = makeFakeStream(objectsFromS3); let {fakeStream} = makeFakeStream(objectsFromS3);
s3.listObjects.returns(fakeStream); s3.listObjects.returns(fakeStream);
const extStorage = new MinIOExternalStorage(dummyBucket, dummyOptions, 42, s3); const extStorage = new MinIOExternalStorage(dummyBucket, dummyOptions, 42, s3 as any);
// when // when
const result = await extStorage.versions(key); const result = await extStorage.versions(key);
@ -142,10 +142,10 @@ describe("MinIOExternalStorage", function () {
const fakeStream = new stream.Readable({objectMode: true}); const fakeStream = new stream.Readable({objectMode: true});
const error = new Error("dummy-error"); const error = new Error("dummy-error");
sandbox.stub(fakeStream, "_read") sandbox.stub(fakeStream, "_read")
.returns(fakeStream) .returns(fakeStream as any)
.callsFake(() => fakeStream.emit('error', error)); .callsFake(() => fakeStream.emit('error', error));
s3.listObjects.returns(fakeStream); s3.listObjects.returns(fakeStream);
const extStorage = new MinIOExternalStorage(dummyBucket, dummyOptions, 42, s3); const extStorage = new MinIOExternalStorage(dummyBucket, dummyOptions, 42, s3 as any);
// when // when
const result = extStorage.versions(key); const result = extStorage.versions(key);
@ -154,4 +154,4 @@ describe("MinIOExternalStorage", function () {
return assert.isRejected(result, error); return assert.isRejected(result, error);
}); });
}); });
}); });

@ -510,39 +510,40 @@
resolved "https://registry.npmjs.org/@sindresorhus/is/-/is-0.14.0.tgz" resolved "https://registry.npmjs.org/@sindresorhus/is/-/is-0.14.0.tgz"
integrity sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ== integrity sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ==
"@sinonjs/commons@^1", "@sinonjs/commons@^1.2.0", "@sinonjs/commons@^1.3.0", "@sinonjs/commons@^1.7.0": "@sinonjs/commons@^2.0.0":
version "1.8.3" version "2.0.0"
resolved "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.3.tgz" resolved "https://registry.npmjs.org/@sinonjs/commons/-/commons-2.0.0.tgz#fd4ca5b063554307e8327b4564bd56d3b73924a3"
integrity sha512-xkNcLAn/wZaX14RPlwizcKicDk9G3F8m2nU3L7Ukm5zBgTwiT0wsoFAHx9Jq56fJA1z/7uKGtCRu16sOUCLIHQ== integrity sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg==
dependencies: dependencies:
type-detect "4.0.8" type-detect "4.0.8"
"@sinonjs/formatio@^3.0.0", "@sinonjs/formatio@^3.2.1": "@sinonjs/commons@^3.0.0":
version "3.2.2" version "3.0.1"
resolved "https://registry.npmjs.org/@sinonjs/formatio/-/formatio-3.2.2.tgz" resolved "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz#1029357e44ca901a615585f6d27738dbc89084cd"
integrity sha512-B8SEsgd8gArBLMD6zpRw3juQ2FVSsmdd7qlevyDqzS9WTCtvF55/gAL+h6gue8ZvPYcdiPdvueM/qm//9XzyTQ== integrity sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==
dependencies: dependencies:
"@sinonjs/commons" "^1" type-detect "4.0.8"
"@sinonjs/samsam" "^3.1.0"
"@sinonjs/samsam@^2.1.2": "@sinonjs/fake-timers@^11.2.2":
version "2.1.3" version "11.2.2"
resolved "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-2.1.3.tgz" resolved "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-11.2.2.tgz#50063cc3574f4a27bd8453180a04171c85cc9699"
integrity sha512-8zNeBkSKhU9a5cRNbpCKau2WWPfan+Q2zDlcXvXyhn9EsMqgYs4qzo0XHNVlXC6ABQL8fT6nV+zzo5RTHJzyXw== integrity sha512-G2piCSxQ7oWOxwGSAyFHfPIsyeJGXYtc6mFbnFA+kRXkiEnTl8c/8jul2S329iFBnDI9HGoeWWAZvuvOkZccgw==
dependencies:
"@sinonjs/commons" "^3.0.0"
"@sinonjs/samsam@^3.1.0": "@sinonjs/samsam@^8.0.0":
version "3.3.3" version "8.0.0"
resolved "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-3.3.3.tgz" resolved "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.0.tgz#0d488c91efb3fa1442e26abea81759dfc8b5ac60"
integrity sha512-bKCMKZvWIjYD0BLGnNrxVuw4dkWCYsLqFOUWw8VgKF/+5Y+mE7LfHWPIYoDXowH+3a9LsWDMo0uAP8YDosPvHQ== integrity sha512-Bp8KUVlLp8ibJZrnvq2foVhP0IVX2CIprMJPK0vqGqgrDa0OHVKeZyBykqskkrdxV6yKBPmGasO8LVjAKR3Gew==
dependencies: dependencies:
"@sinonjs/commons" "^1.3.0" "@sinonjs/commons" "^2.0.0"
array-from "^2.1.1" lodash.get "^4.4.2"
lodash "^4.17.15" type-detect "^4.0.8"
"@sinonjs/text-encoding@^0.7.1": "@sinonjs/text-encoding@^0.7.2":
version "0.7.1" version "0.7.2"
resolved "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.1.tgz" resolved "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.2.tgz#5981a8db18b56ba38ef0efb7d995b12aa7b51918"
integrity sha512-+iTbntw2IZPb/anVDbypzfQa+ay64MW0Zo8aJ8gZPWMMK6/OubMVb6lUPMagqjOPnmtauXnFCACVl3O7ogjeqQ== integrity sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ==
"@socket.io/component-emitter@~3.1.0": "@socket.io/component-emitter@~3.1.0":
version "3.1.0" version "3.1.0"
@ -951,10 +952,17 @@
"@types/mime" "*" "@types/mime" "*"
"@types/node" "*" "@types/node" "*"
"@types/sinon@5.0.5": "@types/sinon@17.0.3":
version "5.0.5" version "17.0.3"
resolved "https://registry.npmjs.org/@types/sinon/-/sinon-5.0.5.tgz" resolved "https://registry.npmjs.org/@types/sinon/-/sinon-17.0.3.tgz#9aa7e62f0a323b9ead177ed23a36ea757141a5fa"
integrity sha512-Wnuv66VhvAD2LEJfZkq8jowXGxe+gjVibeLCYcVBp7QLdw0BFx2sRkKzoiiDkYEPGg5VyqO805Rcj0stVjQwCQ== 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@*": "@types/sizzle@*":
version "2.3.2" 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" resolved "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz"
integrity sha1-ml9pkFGx5wczKPKgCJaLZOopVdI= 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: array-map@~0.0.0:
version "0.0.0" version "0.0.0"
resolved "https://registry.npmjs.org/array-map/-/array-map-0.0.0.tgz" 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" resolved "https://registry.yarnpkg.com/diff/-/diff-5.0.0.tgz#7ed6ad76d859d030787ec35855f5b1daf31d852b"
integrity sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w== integrity sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w==
diff@^3.5.0: diff@^5.1.0:
version "3.5.0" version "5.2.0"
resolved "https://registry.npmjs.org/diff/-/diff-3.5.0.tgz" resolved "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz#26ded047cd1179b78b9537d5ef725503ce1ae531"
integrity sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA== integrity sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==
diffie-hellman@^5.0.0: diffie-hellman@^5.0.0:
version "5.0.3" 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" resolved "https://registry.npmjs.org/is-yarn-global/-/is-yarn-global-0.3.0.tgz"
integrity sha512-VjSeb/lHmkoyd8ryPVIKvOCn4D1koMqY+vqyjjUfc3xyKtP4dYOxM44sZrnqQSzSds3xyOrUTLTC9LVCVgLngw== 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: isarray@~1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" 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" readable-stream "~2.3.6"
setimmediate "^1.0.5" setimmediate "^1.0.5"
just-extend@^4.0.2: just-extend@^6.2.0:
version "4.2.1" version "6.2.0"
resolved "https://registry.npmjs.org/just-extend/-/just-extend-4.2.1.tgz" resolved "https://registry.npmjs.org/just-extend/-/just-extend-6.2.0.tgz#b816abfb3d67ee860482e7401564672558163947"
integrity sha512-g3UB796vUFIY90VIv/WX3L2c8CS2MdWUww3CNrYmqza1Fg0DURc2K/O4YrnklBdQarSJ/y8JnJYDGc+1iumQjg== integrity sha512-cYofQu2Xpom82S6qD778jBDpwvvy39s1l/hrYij2u9AMdQcGRpaBu6kY4mVhuno5kJVi1DAz4aiphA2WI1/OAw==
jwa@^1.4.1: jwa@^1.4.1:
version "1.4.1" version "1.4.1"
@ -5307,18 +5305,6 @@ log-symbols@4.1.0:
chalk "^4.1.0" chalk "^4.1.0"
is-unicode-supported "^0.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: loupe@^2.3.1:
version "2.3.6" version "2.3.6"
resolved "https://registry.yarnpkg.com/loupe/-/loupe-2.3.6.tgz#76e4af498103c532d1ecc9be102036a21f787b53" 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-addon-api "^3.0.0"
node-gyp-build "^4.2.2" node-gyp-build "^4.2.2"
nise@^1.4.6: nise@^5.1.5:
version "1.5.3" version "5.1.9"
resolved "https://registry.npmjs.org/nise/-/nise-1.5.3.tgz" resolved "https://registry.npmjs.org/nise/-/nise-5.1.9.tgz#0cb73b5e4499d738231a473cd89bd8afbb618139"
integrity sha512-Ymbac/94xeIrMf59REBPOv0thr+CJVFMhrlAkW/gjCIE58BGQdCj0x7KRCb3yz+Ga2Rz3E9XXSvUyyxqqhjQAQ== integrity sha512-qOnoujW4SV6e40dYxJOb3uvuoPHtmLzIk4TFo+j0jPJoC+5Z9xja5qH5JZobEPsa8+YYphMrOSwnrshEhG2qww==
dependencies: dependencies:
"@sinonjs/formatio" "^3.2.1" "@sinonjs/commons" "^3.0.0"
"@sinonjs/text-encoding" "^0.7.1" "@sinonjs/fake-timers" "^11.2.2"
just-extend "^4.0.2" "@sinonjs/text-encoding" "^0.7.2"
lolex "^5.0.1" just-extend "^6.2.0"
path-to-regexp "^1.7.0" path-to-regexp "^6.2.1"
node-abort-controller@3.0.1: node-abort-controller@3.0.1:
version "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" resolved "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz"
integrity sha1-32BBeABfUi8V60SQ5yR6G/qmf4w= integrity sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=
path-to-regexp@^1.7.0: path-to-regexp@^6.2.1:
version "1.8.0" version "6.2.1"
resolved "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz" resolved "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.1.tgz#d54934d6798eb9e5ef14e7af7962c945906918e5"
integrity sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA== integrity sha512-JLyh7xT1kizaEvcaXOQwOc2/Yhw6KZOvPf1S8401UyLk86CU79LN3vl7ztXGm/pZ+YjoyAJ4rxmHwbkBXJX+yw==
dependencies:
isarray "0.0.1"
path-type@^4.0.0: path-type@^4.0.0:
version "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" resolved "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz"
integrity sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q== integrity sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==
sinon@7.1.1: sinon@17.0.1:
version "7.1.1" version "17.0.1"
resolved "https://registry.npmjs.org/sinon/-/sinon-7.1.1.tgz" resolved "https://registry.npmjs.org/sinon/-/sinon-17.0.1.tgz#26b8ef719261bf8df43f925924cccc96748e407a"
integrity sha512-iYagtjLVt1vN3zZY7D8oH7dkjNJEjLjyuzy8daX5+3bbQl8gaohrheB9VfH1O3L6LKuue5WTJvFluHiuZ9y3nQ== integrity sha512-wmwE19Lie0MLT+ZYNpDymasPHUKTaZHUH/pKEubRXIzySv9Atnlw+BUMGCzWgV7b7wO+Hw6f1TEOr0IUnmU8/g==
dependencies: dependencies:
"@sinonjs/commons" "^1.2.0" "@sinonjs/commons" "^3.0.0"
"@sinonjs/formatio" "^3.0.0" "@sinonjs/fake-timers" "^11.2.2"
"@sinonjs/samsam" "^2.1.2" "@sinonjs/samsam" "^8.0.0"
diff "^3.5.0" diff "^5.1.0"
lodash.get "^4.4.2" nise "^5.1.5"
lolex "^3.0.0" supports-color "^7.2.0"
nise "^1.4.6"
supports-color "^5.5.0"
type-detect "^4.0.8"
slash@^3.0.0: slash@^3.0.0:
version "3.0.0" version "3.0.0"
@ -7502,7 +7483,7 @@ supports-color@^5.3.0, supports-color@^5.5.0:
dependencies: dependencies:
has-flag "^3.0.0" has-flag "^3.0.0"
supports-color@^7.1.0: supports-color@^7.1.0, supports-color@^7.2.0:
version "7.2.0" version "7.2.0"
resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da"
integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==

Loading…
Cancel
Save