(core) updates from grist-core

pull/936/head
Paul Fitzpatrick 2 weeks ago
commit 4567fad947

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

@ -14,19 +14,6 @@ The `grist-core`, `grist-electron`, and `grist-static` repositories are all open
https://user-images.githubusercontent.com/118367/151245587-892e50a6-41f5-4b74-9786-fe3566f6b1fb.mp4
## 2024 - We're hiring a Systems Engineer!
We are looking for a friendly, capable engineer to join our small
team. You will have broad responsibility for the ease of installation
and maintenance of Grist as an application and service, by our
clients, by self-hosters, and by ourselves.
Read the [full job posting](https://www.getgrist.com/job-systems-engineer/)
or jump into the puzzle that comes with it by just running this:
```
docker run -it gristlabs/grist-twist
```
## Features
Grist is a hybrid database/spreadsheet, meaning that:

@ -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.")),
(!isFeatureEnabled('helpCenter') ? null :
cssIntroLine(
'Learn more in our ', helpCenterLink(), ', or find an expert via our ', sproutsProgram, '.', // TODO i18n
t(
'Learn more in our {{helpCenterLink}}, or find an expert via our {{sproutsProgram}}.',
{helpCenterLink: helpCenterLink(), sproutsProgram}
),
testId('welcome-text')
)
),

@ -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')),
cssParagraph(
t(
'Thank you! Your trust and support is greatly appreciated. ' +
'Opt out any time from the {{link}} in the user menu.',
'Thank you! Your trust and support is greatly appreciated.\
Opt out any time from the {{link}} in the user menu.',
{link: adminPanelLink()},
),
),

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

@ -16,6 +16,7 @@ export const WebhookFields = t.iface([], {
"url": "string",
"eventTypes": t.array(t.union(t.lit("add"), t.lit("update"))),
"tableId": "string",
"watchedColIds": t.opt(t.array("string")),
"enabled": t.opt("boolean"),
"isReadyColumn": t.opt(t.union("string", "null")),
"name": t.opt("string"),
@ -29,6 +30,7 @@ export const WebhookStatus = t.union(t.lit('idle'), t.lit('sending'), t.lit('ret
export const WebhookSubscribe = t.iface([], {
"url": "string",
"eventTypes": t.array(t.union(t.lit("add"), t.lit("update"))),
"watchedColIds": t.opt(t.array("string")),
"enabled": t.opt("boolean"),
"isReadyColumn": t.opt(t.union("string", "null")),
"name": t.opt("string"),
@ -47,6 +49,7 @@ export const WebhookSummary = t.iface([], {
"eventTypes": t.array("string"),
"isReadyColumn": t.union("string", "null"),
"tableId": "string",
"watchedColIds": t.opt(t.array("string")),
"enabled": "boolean",
"name": "string",
"memo": "string",
@ -63,6 +66,7 @@ export const WebhookPatch = t.iface([], {
"url": t.opt("string"),
"eventTypes": t.opt(t.array(t.union(t.lit("add"), t.lit("update")))),
"tableId": t.opt("string"),
"watchedColIds": t.opt(t.array("string")),
"enabled": t.opt("boolean"),
"isReadyColumn": t.opt(t.union("string", "null")),
"name": t.opt("string"),

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

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

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

@ -392,7 +392,7 @@ export class DocWorkerApi {
const tablesTable = activeDoc.docData!.getMetaTable("_grist_Tables");
const trigger = webhookId ? activeDoc.triggers.getWebhookTriggerRecord(webhookId) : undefined;
let currentTableId = trigger ? tablesTable.getValue(trigger.tableRef, 'tableId')! : undefined;
const {url, eventTypes, isReadyColumn, name} = webhook;
const {url, eventTypes, watchedColIds, isReadyColumn, name} = webhook;
const tableId = await getRealTableId(req.params.tableId || webhook.tableId, {metaTables});
const fields: Partial<SchemaTypes['_grist_Triggers']> = {};
@ -409,6 +409,23 @@ export class DocWorkerApi {
}
if (tableId !== undefined) {
if (watchedColIds) {
if (tableId !== currentTableId && currentTableId) {
// if the tableId changed, we need to reset the watchedColIds
fields.watchedColRefList = [GristObjCode.List];
} else {
if (!tableId) {
throw new ApiError(`Cannot find columns "${watchedColIds}" because table is not known`, 404);
}
fields.watchedColRefList = [GristObjCode.List, ...watchedColIds
.filter(colId => colId.trim() !== "")
.map(
colId => { return colIdToReference(metaTables, tableId, colId.trim().replace(/^\$/, '')); }
)];
}
} else {
fields.watchedColRefList = [GristObjCode.List];
}
fields.tableRef = tableIdToRef(metaTables, tableId);
currentTableId = tableId;
}
@ -910,7 +927,6 @@ export class DocWorkerApi {
const docId = activeDoc.docName;
const webhookId = req.params.webhookId;
const {fields, url} = await getWebhookSettings(activeDoc, req, webhookId, req.body);
if (fields.enabled === false) {
await activeDoc.triggers.clearSingleWebhookQueue(webhookId);
}

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

@ -452,7 +452,8 @@ export class FlexServer implements GristServer {
// /status/hooks allows the tests to wait for them to be ready.
// If db=1 query parameter is included, status will include the status of DB connection.
// If redis=1 query parameter is included, status will include the status of the Redis connection.
// If ready=1 query parameter is included, status will include whether the server is fully ready.
// If docWorkerRegistered=1 query parameter is included, status will include the status of the
// doc worker registration in Redis.
this.app.get('/status(/hooks)?', async (req, res) => {
const checks = new Map<string, Promise<boolean>|boolean>();
const timeout = optIntegerParam(req.query.timeout, 'timeout') || 10_000;
@ -474,6 +475,20 @@ export class FlexServer implements GristServer {
if (isParameterOn(req.query.redis)) {
checks.set('redis', asyncCheck(this._docWorkerMap.getRedisClient()?.pingAsync()));
}
if (isParameterOn(req.query.docWorkerRegistered) && this.worker) {
// Only check whether the doc worker is registered if we have a worker.
// The Redis client may not be connected, but in this case this has to
// be checked with the 'redis' parameter (the user may want to avoid
// removing workers when connection is unstable).
if (this._docWorkerMap.getRedisClient()?.connected) {
checks.set('docWorkerRegistered', asyncCheck(
this._docWorkerMap.isWorkerRegistered(this.worker).then(isRegistered => {
if (!isRegistered) { throw new Error('doc worker not registered'); }
return isRegistered;
})
));
}
}
if (isParameterOn(req.query.ready)) {
checks.set('ready', this._isReady);
}

@ -277,6 +277,7 @@ export class DocTriggers {
// Webhook might have been deleted in the mean time.
continue;
}
const decodedWatchedColRefList = decodeObject(t.watchedColRefList) as number[] || [];
// Report some basic info and usage stats.
const entry: WebhookSummary = {
// Id of the webhook
@ -288,6 +289,7 @@ export class DocTriggers {
// Other fields used to register this webhook.
eventTypes: decodeObject(t.eventTypes) as string[],
isReadyColumn: getColId(t.isReadyColRef) ?? null,
watchedColIds: decodedWatchedColRefList.map((columnRef) => getColId(columnRef)),
tableId: getTableId(t.tableRef) ?? null,
// For future use - for now every webhook is enabled.
enabled: t.enabled,
@ -509,6 +511,21 @@ export class DocTriggers {
}
}
if (trigger.watchedColRefList) {
for (const colRef of trigger.watchedColRefList.slice(1)) {
if (!this._validateColId(colRef as number, trigger.tableRef)) {
// column does not belong to table, let's ignore trigger and log stats
for (const action of webhookActions) {
const colId = this._getColId(colRef as number); // no validation
const tableId = this._getTableId(trigger.tableRef);
const error = `column is not valid: colId ${colId} does not belong to ${tableId}`;
this._stats.logInvalid(action.id, error).catch(e => log.error("Webhook stats failed to log", e));
}
continue;
}
}
}
// TODO: would be worth checking that the trigger's fields are valid (ie: eventTypes, url,
// ...) as there's no guarantee that they are.
@ -585,9 +602,21 @@ export class DocTriggers {
}
}
const colIdsToCheck: Array<string> = [];
if (trigger.watchedColRefList) {
for (const colRef of trigger.watchedColRefList.slice(1)) {
colIdsToCheck.push(this._getColId(colRef as number)!);
}
}
let eventType: EventType;
if (readyBefore) {
eventType = "update";
// check if any of the columns to check were changed to consider this an update
if (colIdsToCheck.length === 0 || colIdsToCheck.some(colId => tableDelta.columnDeltas[colId]?.[rowId])) {
eventType = "update";
} else {
return false;
}
// If we allow subscribing to deletion in the future
// if (recordDelta.existedAfter) {
// eventType = "update";

@ -6,7 +6,7 @@ export const GRIST_DOC_SQL = `
PRAGMA foreign_keys=OFF;
BEGIN TRANSACTION;
CREATE TABLE IF NOT EXISTS "_grist_DocInfo" (id INTEGER PRIMARY KEY, "docId" TEXT DEFAULT '', "peers" TEXT DEFAULT '', "basketId" TEXT DEFAULT '', "schemaVersion" INTEGER DEFAULT 0, "timezone" TEXT DEFAULT '', "documentSettings" TEXT DEFAULT '');
INSERT INTO _grist_DocInfo VALUES(1,'','','',41,'','');
INSERT INTO _grist_DocInfo VALUES(1,'','','',42,'','');
CREATE TABLE IF NOT EXISTS "_grist_Tables" (id INTEGER PRIMARY KEY, "tableId" TEXT DEFAULT '', "primaryViewId" INTEGER DEFAULT 0, "summarySourceTable" INTEGER DEFAULT 0, "onDemand" BOOLEAN DEFAULT 0, "rawViewSectionRef" INTEGER DEFAULT 0, "recordCardViewSectionRef" INTEGER DEFAULT 0);
CREATE TABLE IF NOT EXISTS "_grist_Tables_column" (id INTEGER PRIMARY KEY, "parentId" INTEGER DEFAULT 0, "parentPos" NUMERIC DEFAULT 1e999, "colId" TEXT DEFAULT '', "type" TEXT DEFAULT '', "widgetOptions" TEXT DEFAULT '', "isFormula" BOOLEAN DEFAULT 0, "formula" TEXT DEFAULT '', "label" TEXT DEFAULT '', "description" TEXT DEFAULT '', "untieColIdFromLabel" BOOLEAN DEFAULT 0, "summarySourceCol" INTEGER DEFAULT 0, "displayCol" INTEGER DEFAULT 0, "visibleCol" INTEGER DEFAULT 0, "rules" TEXT DEFAULT NULL, "recalcWhen" INTEGER DEFAULT 0, "recalcDeps" TEXT DEFAULT NULL);
CREATE TABLE IF NOT EXISTS "_grist_Imports" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "origFileName" TEXT DEFAULT '', "parseFormula" TEXT DEFAULT '', "delimiter" TEXT DEFAULT '', "doublequote" BOOLEAN DEFAULT 0, "escapechar" TEXT DEFAULT '', "quotechar" TEXT DEFAULT '', "skipinitialspace" BOOLEAN DEFAULT 0, "encoding" TEXT DEFAULT '', "hasHeaders" BOOLEAN DEFAULT 0);
@ -22,7 +22,7 @@ CREATE TABLE IF NOT EXISTS "_grist_Views_section_field" (id INTEGER PRIMARY KEY,
CREATE TABLE IF NOT EXISTS "_grist_Validations" (id INTEGER PRIMARY KEY, "formula" TEXT DEFAULT '', "name" TEXT DEFAULT '', "tableRef" INTEGER DEFAULT 0);
CREATE TABLE IF NOT EXISTS "_grist_REPL_Hist" (id INTEGER PRIMARY KEY, "code" TEXT DEFAULT '', "outputText" TEXT DEFAULT '', "errorText" TEXT DEFAULT '');
CREATE TABLE IF NOT EXISTS "_grist_Attachments" (id INTEGER PRIMARY KEY, "fileIdent" TEXT DEFAULT '', "fileName" TEXT DEFAULT '', "fileType" TEXT DEFAULT '', "fileSize" INTEGER DEFAULT 0, "fileExt" TEXT DEFAULT '', "imageHeight" INTEGER DEFAULT 0, "imageWidth" INTEGER DEFAULT 0, "timeDeleted" DATETIME DEFAULT NULL, "timeUploaded" DATETIME DEFAULT NULL);
CREATE TABLE IF NOT EXISTS "_grist_Triggers" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "eventTypes" TEXT DEFAULT NULL, "isReadyColRef" INTEGER DEFAULT 0, "actions" TEXT DEFAULT '', "label" TEXT DEFAULT '', "memo" TEXT DEFAULT '', "enabled" BOOLEAN DEFAULT 0);
CREATE TABLE IF NOT EXISTS "_grist_Triggers" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "eventTypes" TEXT DEFAULT NULL, "isReadyColRef" INTEGER DEFAULT 0, "actions" TEXT DEFAULT '', "label" TEXT DEFAULT '', "memo" TEXT DEFAULT '', "enabled" BOOLEAN DEFAULT 0, "watchedColRefList" TEXT DEFAULT NULL, "options" TEXT DEFAULT '');
CREATE TABLE IF NOT EXISTS "_grist_ACLRules" (id INTEGER PRIMARY KEY, "resource" INTEGER DEFAULT 0, "permissions" INTEGER DEFAULT 0, "principals" TEXT DEFAULT '', "aclFormula" TEXT DEFAULT '', "aclColumn" INTEGER DEFAULT 0, "aclFormulaParsed" TEXT DEFAULT '', "permissionsText" TEXT DEFAULT '', "rulePos" NUMERIC DEFAULT 1e999, "userAttributes" TEXT DEFAULT '', "memo" TEXT DEFAULT '');
INSERT INTO _grist_ACLRules VALUES(1,1,63,'[1]','',0,'','',1e999,'','');
CREATE TABLE IF NOT EXISTS "_grist_ACLResources" (id INTEGER PRIMARY KEY, "tableId" TEXT DEFAULT '', "colIds" TEXT DEFAULT '');
@ -44,7 +44,7 @@ export const GRIST_DOC_WITH_TABLE1_SQL = `
PRAGMA foreign_keys=OFF;
BEGIN TRANSACTION;
CREATE TABLE IF NOT EXISTS "_grist_DocInfo" (id INTEGER PRIMARY KEY, "docId" TEXT DEFAULT '', "peers" TEXT DEFAULT '', "basketId" TEXT DEFAULT '', "schemaVersion" INTEGER DEFAULT 0, "timezone" TEXT DEFAULT '', "documentSettings" TEXT DEFAULT '');
INSERT INTO _grist_DocInfo VALUES(1,'','','',41,'','');
INSERT INTO _grist_DocInfo VALUES(1,'','','',42,'','');
CREATE TABLE IF NOT EXISTS "_grist_Tables" (id INTEGER PRIMARY KEY, "tableId" TEXT DEFAULT '', "primaryViewId" INTEGER DEFAULT 0, "summarySourceTable" INTEGER DEFAULT 0, "onDemand" BOOLEAN DEFAULT 0, "rawViewSectionRef" INTEGER DEFAULT 0, "recordCardViewSectionRef" INTEGER DEFAULT 0);
INSERT INTO _grist_Tables VALUES(1,'Table1',1,0,0,2,3);
CREATE TABLE IF NOT EXISTS "_grist_Tables_column" (id INTEGER PRIMARY KEY, "parentId" INTEGER DEFAULT 0, "parentPos" NUMERIC DEFAULT 1e999, "colId" TEXT DEFAULT '', "type" TEXT DEFAULT '', "widgetOptions" TEXT DEFAULT '', "isFormula" BOOLEAN DEFAULT 0, "formula" TEXT DEFAULT '', "label" TEXT DEFAULT '', "description" TEXT DEFAULT '', "untieColIdFromLabel" BOOLEAN DEFAULT 0, "summarySourceCol" INTEGER DEFAULT 0, "displayCol" INTEGER DEFAULT 0, "visibleCol" INTEGER DEFAULT 0, "rules" TEXT DEFAULT NULL, "recalcWhen" INTEGER DEFAULT 0, "recalcDeps" TEXT DEFAULT NULL);
@ -80,7 +80,7 @@ INSERT INTO _grist_Views_section_field VALUES(9,3,9,4,0,'',0,0,'',NULL);
CREATE TABLE IF NOT EXISTS "_grist_Validations" (id INTEGER PRIMARY KEY, "formula" TEXT DEFAULT '', "name" TEXT DEFAULT '', "tableRef" INTEGER DEFAULT 0);
CREATE TABLE IF NOT EXISTS "_grist_REPL_Hist" (id INTEGER PRIMARY KEY, "code" TEXT DEFAULT '', "outputText" TEXT DEFAULT '', "errorText" TEXT DEFAULT '');
CREATE TABLE IF NOT EXISTS "_grist_Attachments" (id INTEGER PRIMARY KEY, "fileIdent" TEXT DEFAULT '', "fileName" TEXT DEFAULT '', "fileType" TEXT DEFAULT '', "fileSize" INTEGER DEFAULT 0, "fileExt" TEXT DEFAULT '', "imageHeight" INTEGER DEFAULT 0, "imageWidth" INTEGER DEFAULT 0, "timeDeleted" DATETIME DEFAULT NULL, "timeUploaded" DATETIME DEFAULT NULL);
CREATE TABLE IF NOT EXISTS "_grist_Triggers" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "eventTypes" TEXT DEFAULT NULL, "isReadyColRef" INTEGER DEFAULT 0, "actions" TEXT DEFAULT '', "label" TEXT DEFAULT '', "memo" TEXT DEFAULT '', "enabled" BOOLEAN DEFAULT 0);
CREATE TABLE IF NOT EXISTS "_grist_Triggers" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "eventTypes" TEXT DEFAULT NULL, "isReadyColRef" INTEGER DEFAULT 0, "actions" TEXT DEFAULT '', "label" TEXT DEFAULT '', "memo" TEXT DEFAULT '', "enabled" BOOLEAN DEFAULT 0, "watchedColRefList" TEXT DEFAULT NULL, "options" TEXT DEFAULT '');
CREATE TABLE IF NOT EXISTS "_grist_ACLRules" (id INTEGER PRIMARY KEY, "resource" INTEGER DEFAULT 0, "permissions" INTEGER DEFAULT 0, "principals" TEXT DEFAULT '', "aclFormula" TEXT DEFAULT '', "aclColumn" INTEGER DEFAULT 0, "aclFormulaParsed" TEXT DEFAULT '', "permissionsText" TEXT DEFAULT '', "rulePos" NUMERIC DEFAULT 1e999, "userAttributes" TEXT DEFAULT '', "memo" TEXT DEFAULT '');
INSERT INTO _grist_ACLRules VALUES(1,1,63,'[1]','',0,'','',1e999,'','');
CREATE TABLE IF NOT EXISTS "_grist_ACLResources" (id INTEGER PRIMARY KEY, "tableId" TEXT DEFAULT '', "colIds" TEXT DEFAULT '');

@ -103,6 +103,12 @@ export function allowHost(req: IncomingMessage, allowedHost: string|URL) {
const proto = getEndUserProtocol(req);
const actualUrl = new URL(getOriginUrl(req));
const allowedUrl = (typeof allowedHost === 'string') ? new URL(`${proto}://${allowedHost}`) : allowedHost;
log.rawDebug('allowHost: ', {
req: (new URL(req.url!, `http://${req.headers.host}`).href),
origin: req.headers.origin,
actualUrl: actualUrl.hostname,
allowedUrl: allowedUrl.hostname,
});
if ((req as RequestWithOrg).isCustomHost) {
// For a request to a custom domain, the full hostname must match.
return actualUrl.hostname === allowedUrl.hostname;

@ -409,6 +409,12 @@ export async function fetchDoc(server: GristServer, docId: string, req: Request,
// Prepare headers that preserve credentials of current user.
const headers = getTransitiveHeaders(req);
// Passing the Origin header would serve no purpose here, as we are
// constructing an internal request to fetch from our own doc worker
// URL. Indeed, it may interfere, as it could incur a CORS check in
// `trustOrigin`, which we do not need.
delete headers.Origin;
// Find the doc worker responsible for the document we wish to copy.
// The backend needs to be well configured for this to work.
const homeUrl = server.getHomeUrl(req);

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

@ -13,12 +13,13 @@
"install:python3": "buildtools/prepare_python3.sh",
"build:prod": "buildtools/build.sh",
"start:prod": "sandbox/run.sh",
"test": "GRIST_SESSION_COOKIE=grist_test_cookie GRIST_TEST_LOGIN=1 TEST_SUPPORT_API_KEY=api_key_for_support TEST_CLEAN_DATABASE=true LANGUAGE=en_US mocha ${DEBUG:+-b --no-exit} --slow 8000 $([ -z $DEBUG ] && echo --forbid-only) -g \"${GREP_TESTS}\" '_build/test/common/*.js' '_build/test/client/*.js' '_build/test/nbrowser/*.js' '_build/test/server/**/*.js' '_build/test/gen-server/**/*.js'",
"test": "GRIST_SESSION_COOKIE=grist_test_cookie GRIST_TEST_LOGIN=1 TEST_SUPPORT_API_KEY=api_key_for_support TEST_CLEAN_DATABASE=true LANGUAGE=en_US mocha ${DEBUG:+-b --no-exit} --slow 8000 $([ -z $DEBUG ] && echo --forbid-only) -g \"${GREP_TESTS}\" '_build/test/common/*.js' '_build/test/client/*.js' '_build/test/nbrowser/*.js' '_build/test/nbrowser_with_stubs/**/*.js' '_build/test/server/**/*.js' '_build/test/gen-server/**/*.js'",
"test:nbrowser": "TEST_SUITE=nbrowser TEST_SUITE_FOR_TIMINGS=nbrowser TIMINGS_FILE=test/timings/nbrowser.txt GRIST_SESSION_COOKIE=grist_test_cookie GRIST_TEST_LOGIN=1 TEST_SUPPORT_API_KEY=api_key_for_support TEST_CLEAN_DATABASE=true LANGUAGE=en_US mocha ${DEBUG:+-b --no-exit} $([ -z $DEBUG ] && echo --forbid-only) -g \"${GREP_TESTS}\" --slow 8000 -R test/xunit-file '_build/test/nbrowser/**/*.js'",
"test:stubs": "GRIST_SESSION_COOKIE=grist_test_cookie GRIST_TEST_LOGIN=1 TEST_SUPPORT_API_KEY=api_key_for_support TEST_CLEAN_DATABASE=true LANGUAGE=en_US mocha ${DEBUG:+-b --no-exit} $([ -z $DEBUG ] && echo --forbid-only) -g \"${GREP_TESTS}\" --slow 8000 -R test/xunit-file '_build/test/nbrowser_with_stubs/**/*.js'",
"test:client": "GRIST_SESSION_COOKIE=grist_test_cookie mocha ${DEBUG:+'-b'} '_build/test/client/**/*.js'",
"test:common": "GRIST_SESSION_COOKIE=grist_test_cookie mocha ${DEBUG:+'-b'} '_build/test/common/**/*.js'",
"test:server": "TEST_CLEAN_DATABASE=true TEST_SUITE=server TEST_SUITE_FOR_TIMINGS=server TIMINGS_FILE=test/timings/server.txt GRIST_SESSION_COOKIE=grist_test_cookie mocha ${DEBUG:+'-b'} -g \"${GREP_TESTS}\" -R test/xunit-file '_build/test/server/**/*.js' '_build/test/gen-server/**/*.js'",
"test:smoke": "mocha _build/test/nbrowser/Smoke.js",
"test:smoke": "LANGUAGE=en_US mocha _build/test/nbrowser/Smoke.js",
"test:docker": "./test/test_under_docker.sh",
"test:python": "sandbox_venv3/bin/python sandbox/grist/runtests.py ${GREP_TESTS:+discover -p \"test*${GREP_TESTS}*.py\"}",
"cli": "NODE_PATH=_build:_build/stubs:_build/ext node _build/app/server/companion.js",
@ -76,7 +77,7 @@
"@types/redlock": "3.0.2",
"@types/saml2-js": "2.0.1",
"@types/selenium-webdriver": "4.1.15",
"@types/sinon": "5.0.5",
"@types/sinon": "17.0.3",
"@types/sqlite3": "3.1.6",
"@types/swagger-ui": "3.52.4",
"@types/tmp": "0.0.33",
@ -100,7 +101,7 @@
"nodemon": "^2.0.4",
"otplib": "12.0.1",
"proper-lockfile": "4.1.2",
"sinon": "7.1.1",
"sinon": "17.0.1",
"source-map-loader": "^0.2.4",
"tmp-promise": "1.0.5",
"ts-interface-builder": "0.3.2",

@ -1307,3 +1307,13 @@ def migration41(tdset):
]
return tdset.apply_doc_actions(doc_actions)
@migration(schema_version=42)
def migration42(tdset):
"""
Adds column to register which table columns are triggered in webhooks.
"""
return tdset.apply_doc_actions([
add_column('_grist_Triggers', 'watchedColRefList', 'RefList:_grist_Tables_column'),
add_column('_grist_Triggers', 'options', 'Text'),
])

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

@ -233,7 +233,8 @@
"Compare to Previous": "Mit vorherigem vergleichen",
"Open Snapshot": "Schnappschuss öffnen",
"Snapshots": "Schnappschüsse",
"Snapshots are unavailable.": "Schnappschüsse sind nicht verfügbar."
"Snapshots are unavailable.": "Schnappschüsse sind nicht verfügbar.",
"Only owners have access to snapshots for documents with access rules.": "Nur Eigentümer haben Zugriff auf Snapshots für Dokumente mit Zugriffsregeln."
},
"DocMenu": {
"(The organization needs a paid plan)": "(Die Organisation benötigt einen bezahlten Plan)",
@ -700,7 +701,9 @@
"Default field value": "Standard-Feldwert",
"Field title": "Feldtitel",
"Hidden field": "Verborgenes Feld",
"Submit button label": "Beschriftung der Schaltfläche Senden"
"Submit button label": "Beschriftung der Schaltfläche Senden",
"No field selected": "Kein Feld ausgewählt",
"Select a field in the form widget to configure.": "Wählen Sie ein Feld im Formular Widget aus, um es zu konfigurieren."
},
"RowContextMenu": {
"Copy anchor link": "Ankerlink kopieren",
@ -712,7 +715,8 @@
"Insert row below": "Zeile unten einfügen",
"Duplicate rows_one": "Zeile duplizieren",
"Duplicate rows_other": "Zeilen duplizieren",
"View as card": "Ansicht als Karte"
"View as card": "Ansicht als Karte",
"Use as table headers": "Verwendung als Tabellenüberschriften"
},
"SelectionSummary": {
"Copied to clipboard": "In die Zwischenablage kopiert"
@ -738,7 +742,12 @@
"Unsaved": "Ungespeichert",
"Work on a Copy": "Arbeiten an einer Kopie",
"Share": "Teilen",
"Download...": "Herunterladen..."
"Download...": "Herunterladen...",
"Export as...": "Exportieren als...",
"Microsoft Excel (.xlsx)": "Microsoft Excel (.xlsx)",
"Tab Separated Values (.tsv)": "Tabulatorgetrennte Werte (.tsv)",
"Comma Separated Values (.csv)": "Kommagetrennte Werte (.csv)",
"DOO Separated Values (.dsv)": "DOO-getrennte Werte (.dsv)"
},
"SiteSwitcher": {
"Create new team site": "Neue Teamseite erstellen",
@ -1279,7 +1288,8 @@
"Help Center": "Hilfe-Center",
"Opt in to Telemetry": "Melden Sie sich für Telemetrie an",
"Opted In": "Angemeldet",
"Support Grist page": "Support Grist-Seite"
"Support Grist page": "Support Grist-Seite",
"Admin Panel": "Verwaltungsbereich"
},
"SupportGristPage": {
"GitHub Sponsors page": "GitHub-Sponsorenseite",
@ -1297,7 +1307,8 @@
"Support Grist": "Grist Support",
"Telemetry": "Telemetrie",
"You have opted in to telemetry. Thank you!": "Sie haben sich für die Telemetrie entschieden. Vielen Dank!",
"You have opted out of telemetry.": "Sie haben sich von der Telemetrie abgemeldet."
"You have opted out of telemetry.": "Sie haben sich von der Telemetrie abgemeldet.",
"Sponsor": "Sponsor"
},
"buildViewSectionDom": {
"No row selected in {{title}}": "Keine Zeile in {{title}} ausgewählt",
@ -1381,7 +1392,21 @@
"Publish your form?": "Ihr Formular veröffentlichen?",
"Unpublish": "Unveröffentlichen",
"Unpublish your form?": "Ihr Formular unveröffentlichen?",
"Publish": "Veröffentlichen"
"Publish": "Veröffentlichen",
"Preview": "Vorschau",
"Reset": "Zurücksetzen",
"Share": "Teilen",
"Are you sure you want to reset your form?": "Sind Sie sicher, dass Sie Ihr Formular zurücksetzen möchten?",
"Reset form": "Formular zurücksetzen",
"Save your document to publish this form.": "Speichern Sie Ihr Dokument, um dieses Formular zu veröffentlichen.",
"Anyone with the link below can see the empty form and submit a response.": "Jeder, der den unten stehenden Link anklickt, kann das leere Formular sehen und eine Antwort einreichen.",
"Code copied to clipboard": "Code in die Zwischenablage kopiert",
"Copy code": "Code kopieren",
"Embed this form": "Dieses Formular einbetten",
"Copy link": "Link kopieren",
"Link copied to clipboard": "Link in die Zwischenablage kopiert",
"Share this form": "Dieses Formular teilen",
"View": "Ansicht"
},
"Editor": {
"Delete": "Löschen"
@ -1451,5 +1476,29 @@
"This week": "Diese Woche",
"This year": "Dieses Jahr",
"Today": "Heute"
},
"MappedFieldsConfig": {
"Clear": "Löschen",
"Map fields": "Felder zuordnen",
"Mapped": "Zugeordnet",
"Unmap fields": "Felder freigeben",
"Unmapped": "Nicht zugeordnet",
"Select All": "Alle auswählen"
},
"AdminPanel": {
"Admin Panel": "Verwaltungsbereich",
"Current": "Aktuell",
"Support Grist": "Unterstützen Sie Grist",
"Telemetry": "Telemetrie",
"Current version of Grist": "Aktuelle Version von Grist",
"Help us make Grist better": "Helfen Sie uns, Grist besser zu machen",
"Home": "Home",
"Sponsor": "Sponsor",
"Support Grist Labs on GitHub": "Unterstützen Sie Grist Labs auf GitHub",
"Version": "Version"
},
"Section": {
"Insert section above": "Abschnitt oben einfügen",
"Insert section below": "Abschnitt unten einfügen"
}
}

@ -225,7 +225,8 @@
"Compare to Previous": "Compare to Previous",
"Open Snapshot": "Open Snapshot",
"Snapshots": "Snapshots",
"Snapshots are unavailable.": "Snapshots are unavailable."
"Snapshots are unavailable.": "Snapshots are unavailable.",
"Only owners have access to snapshots for documents with access rules.": "Only owners have access to snapshots for documents with access rules."
},
"DocMenu": {
"(The organization needs a paid plan)": "(The organization needs a paid plan)",
@ -480,7 +481,8 @@
"Welcome to {{- orgName}}": "Welcome to {{- orgName}}",
"Sign in": "Sign in",
"To use Grist, please either sign up or sign in.": "To use Grist, please either sign up or sign in.",
"Visit our {{link}} to learn more about Grist.": "Visit our {{link}} to learn more about Grist."
"Visit our {{link}} to learn more about Grist.": "Visit our {{link}} to learn more about Grist.",
"Learn more in our {{helpCenterLink}}, or find an expert via our {{sproutsProgram}}.": "Learn more in our {{helpCenterLink}}, or find an expert via our {{sproutsProgram}}."
},
"HomeLeftPane": {
"Access Details": "Access Details",
@ -1168,7 +1170,21 @@
},
"WebhookPage": {
"Clear Queue": "Clear Queue",
"Webhook Settings": "Webhook Settings"
"Webhook Settings": "Webhook Settings",
"Cleared webhook queue.": "Cleared webhook queue.",
"Columns to check when update (separated by ;)": "Columns to check when update (separated by ;)",
"Enabled": "Enabled",
"Event Types": "Event Types",
"Memo": "Memo",
"Name": "Name",
"Ready Column": "Ready Column",
"Removed webhook.": "Removed webhook.",
"Sorry, not all fields can be edited.": "Sorry, not all fields can be edited.",
"Status": "Status",
"URL": "URL",
"Webhook Id": "Webhook Id",
"Table": "Table",
"Filter for changes in these columns (semicolon-separated ids)": "Filter for changes in these columns (semicolon-separated ids)"
},
"FormulaAssistant": {
"Ask the bot.": "Ask the bot.",
@ -1278,7 +1294,9 @@
"Opt in to Telemetry": "Opt in to Telemetry",
"Opted In": "Opted In",
"Support Grist": "Support Grist",
"Support Grist page": "Support Grist page"
"Support Grist page": "Support Grist page",
"Thank you! Your trust and support is greatly appreciated. Opt out any time from the {{link}} in the user menu.": "Thank you! Your trust and support is greatly appreciated. Opt out any time from the {{link}} in the user menu.",
"Admin Panel": "Admin Panel"
},
"SupportGristPage": {
"GitHub": "GitHub",
@ -1296,7 +1314,8 @@
"We only collect usage statistics, as detailed in our {{link}}, never document contents.": "We only collect usage statistics, as detailed in our {{link}}, never document contents.",
"You can opt out of telemetry at any time from this page.": "You can opt out of telemetry at any time from this page.",
"You have opted in to telemetry. Thank you!": "You have opted in to telemetry. Thank you!",
"You have opted out of telemetry.": "You have opted out of telemetry."
"You have opted out of telemetry.": "You have opted out of telemetry.",
"Sponsor": "Sponsor"
},
"buildViewSectionDom": {
"No data": "No data",
@ -1421,5 +1440,31 @@
"Section": {
"Insert section above": "Insert section above",
"Insert section below": "Insert section below"
},
"CreateTeamModal": {
"Cancel": "Cancel",
"Choose a name and url for your team site": "Choose a name and url for your team site",
"Create site": "Create site",
"Domain name is invalid": "Domain name is invalid",
"Domain name is required": "Domain name is required",
"Go to your site": "Go to your site",
"Team name": "Team name",
"Team name is required": "Team name is required",
"Team site created": "Team site created",
"Team url": "Team url",
"Work as a Team": "Work as a Team",
"Billing is not supported in grist-core": "Billing is not supported in grist-core"
},
"AdminPanel": {
"Admin Panel": "Admin Panel",
"Current": "Current",
"Current version of Grist": "Current version of Grist",
"Help us make Grist better": "Help us make Grist better",
"Home": "Home",
"Sponsor": "Sponsor",
"Support Grist": "Support Grist",
"Support Grist Labs on GitHub": "Support Grist Labs on GitHub",
"Telemetry": "Telemetry",
"Version": "Version"
}
}

@ -155,7 +155,7 @@
"Others": "Otros",
"Search": "Buscar",
"Search values": "Buscar valores",
"Start": "Inicio",
"Start": "Iniciar",
"Filter by Range": "Filtrar por rango"
},
"CustomSectionConfig": {
@ -187,7 +187,8 @@
"Compare to Previous": "Comparar con el anterior",
"Open Snapshot": "Abrir instantánea",
"Snapshots": "Instantáneas",
"Snapshots are unavailable.": "Las instantáneas no están disponibles."
"Snapshots are unavailable.": "Las instantáneas no están disponibles.",
"Only owners have access to snapshots for documents with access rules.": "Solo los dueños tienen acceso a las instantáneas de los documentos con unas reglas de acceso."
},
"DocMenu": {
"(The organization needs a paid plan)": "(La organización necesita un plan de pago)",
@ -1277,7 +1278,8 @@
"Opt in to Telemetry": "Participar en Telemetría",
"Support Grist page": "Página de soporte de Grist",
"Close": "Cerrar",
"Contribute": "Contribuir"
"Contribute": "Contribuir",
"Admin Panel": "Panel de control"
},
"SupportGristPage": {
"GitHub": "GitHub",
@ -1295,7 +1297,8 @@
"You can opt out of telemetry at any time from this page.": "Puede cancelar la telemetría en cualquier momento desde esta página.",
"You have opted in to telemetry. Thank you!": "Ha optado por la telemetría. ¡Gracias!",
"You have opted out of telemetry.": "Ha optado por no participar en la telemetría.",
"Home": "Inicio"
"Home": "Inicio",
"Sponsor": "Patrocinador"
},
"buildViewSectionDom": {
"No data": "Sin datos",
@ -1475,5 +1478,17 @@
"Section": {
"Insert section above": "Insertar la sección anterior",
"Insert section below": "Insertar la sección siguiente"
},
"AdminPanel": {
"Current": "Actual",
"Help us make Grist better": "Ayúdanos a mejorar Grist",
"Home": "Inicio",
"Sponsor": "Patrocinador",
"Support Grist": "Soporte Grist",
"Telemetry": "Telemetría",
"Version": "Versión",
"Current version of Grist": "Versión actual de Grist",
"Admin Panel": "Panel de control",
"Support Grist Labs on GitHub": "Apoya a Grist Labs en GitHub"
}
}

@ -221,7 +221,8 @@
"Compare to Current": "Comparer au document en cours",
"Compare to Previous": "Comparer au précédent",
"Beta": "Bêta",
"Snapshots are unavailable.": "Les sauvegardes ne sont pas disponibles."
"Snapshots are unavailable.": "Les sauvegardes ne sont pas disponibles.",
"Only owners have access to snapshots for documents with access rules.": "Seuls les propriétaires ont accès aux instantanés des documents soumis à des règles d'accès."
},
"DocMenu": {
"Other Sites": "Autres espaces",
@ -652,7 +653,9 @@
"Enter redirect URL": "Saisir l'URL de redirection",
"Reset form": "Réinitialiser le formulaire",
"Submit another response": "Soumettre une autre réponse",
"Required field": "Champ obligatoire"
"Required field": "Champ obligatoire",
"No field selected": "Aucun champ sélectionné",
"Select a field in the form widget to configure.": "Sélectionnez un champ du formulaire à configurer."
},
"RowContextMenu": {
"Insert row": "Insérer une ligne",
@ -662,7 +665,8 @@
"Duplicate rows_other": "Dupliquer les lignes",
"Delete": "Supprimer",
"Copy anchor link": "Copier l'ancre",
"View as card": "Voir en carte"
"View as card": "Voir en carte",
"Use as table headers": "Utiliser en tant qu'en-têtes de table"
},
"SelectionSummary": {
"Copied to clipboard": "Copié dans le presse-papier"
@ -688,7 +692,9 @@
"Export XLSX": "Exporter en XLSX",
"Send to Google Drive": "Envoyer vers Google Drive",
"Share": "Partager",
"Download...": "Télécharger..."
"Download...": "Télécharger...",
"Microsoft Excel (.xlsx)": "Microsoft Excel (.xlsx)",
"Export as...": "Exporter en tant que..."
},
"SiteSwitcher": {
"Switch Sites": "Changer despace",
@ -1209,7 +1215,8 @@
"Support Grist": "Support Grist",
"Opt in to Telemetry": "S'inscrire à l'envoi de données de télémétrie",
"Opted In": "Accepté",
"Support Grist page": "Soutenir Grist"
"Support Grist page": "Soutenir Grist",
"Admin Panel": "Panneau d'administration"
},
"GridView": {
"Click to insert": "Cliquer pour insérer"
@ -1218,8 +1225,8 @@
"GitHub": "GitHub",
"Help Center": "Centre d'aide",
"Home": "Accueil",
"Sponsor Grist Labs on GitHub": "Sponsoriser Grist Labs sur GitHub",
"GitHub Sponsors page": "Page de sponsors GitHub",
"Sponsor Grist Labs on GitHub": "Parrainer Grist Labs sur GitHub",
"GitHub Sponsors page": "Page de parrainage GitHub",
"Manage Sponsorship": "Gérer le parrainage",
"Opt in to Telemetry": "S'inscrire à l'envoi de données de télémétrie",
"Opt out of Telemetry": "Se désinscrire de l'envoi de données de télémétrie",
@ -1230,7 +1237,8 @@
"You have opted out of telemetry.": "Vous avez choisi de ne pas envoyer de données de télémétrie.",
"We only collect usage statistics, as detailed in our {{link}}, never document contents.": "Nous ne collectons que des statistiques d'usage, comme détaillé dans notre {{link}}, jamais le contenu des documents.",
"You can opt out of telemetry at any time from this page.": "Vous pouvez vous désinscrire de la télémétrie à tout moment depuis cette page.",
"You have opted in to telemetry. Thank you!": "Merci de vous être inscrit à la télémétrie !"
"You have opted in to telemetry. Thank you!": "Merci de vous être inscrit à la télémétrie !",
"Sponsor": "Parrainage"
},
"buildViewSectionDom": {
"No data": "Aucune donnée",
@ -1323,7 +1331,21 @@
"Publish": "Publier",
"Publish your form?": "Publier votre formulaire?",
"Unpublish": "Dépublier",
"Unpublish your form?": "Dépublier votre formulaire?"
"Unpublish your form?": "Dépublier votre formulaire?",
"Are you sure you want to reset your form?": "Êtes-vous sûr de vouloir réinitialiser votre formulaire?",
"Anyone with the link below can see the empty form and submit a response.": "Toute personne ayant accès au lien ci-dessous peut voir le formulaire vide et soumettre une réponse.",
"Code copied to clipboard": "Code copié dans le presse-papiers",
"Copy code": "Copier le code",
"View": "Vue",
"Copy link": "Copier le lien",
"Embed this form": "Intégrer ce formulaire",
"Link copied to clipboard": "Lien copié dans le presse-papiers",
"Preview": "Aperçu",
"Reset form": "Réinitialiser le formulaire",
"Save your document to publish this form.": "Enregistrez votre document pour publier ce formulaire.",
"Share this form": "Partager ce formulaire",
"Reset": "Réinitialiser",
"Share": "Partager"
},
"HiddenQuestionConfig": {
"Hidden fields": "Champs cachés"
@ -1389,5 +1411,29 @@
"This week": "Cette semaine",
"This year": "Cette année",
"Today": "Aujourd'hui"
},
"MappedFieldsConfig": {
"Mapped": "Utilisé",
"Select All": "Sélectionner tout",
"Unmap fields": "Champs non utilisés",
"Map fields": "Champs utilisés",
"Clear": "Effacer",
"Unmapped": "Non utilisé"
},
"Section": {
"Insert section above": "Ajouter une section ci-dessus",
"Insert section below": "Ajouter une section ci-dessous"
},
"AdminPanel": {
"Current": "Actuelle",
"Current version of Grist": "Version actuelle de Grist",
"Help us make Grist better": "Aidez-nous à améliorer Grist",
"Home": "Accueil",
"Telemetry": "Télémétrie",
"Support Grist Labs on GitHub": "Soutenir Grist Labs sur GitHub",
"Admin Panel": "Panneau d'administration",
"Sponsor": "Parrainage",
"Support Grist": "Soutenir Grist",
"Version": "Version"
}
}

@ -63,7 +63,8 @@
"Compare to Current": "Comparar ao atual",
"Compare to Previous": "Comparar ao anterior",
"Open Snapshot": "Abrir Instantâneo",
"Snapshots are unavailable.": "Os instantâneos não estão disponíveis."
"Snapshots are unavailable.": "Os instantâneos não estão disponíveis.",
"Only owners have access to snapshots for documents with access rules.": "Apenas os proprietários têm acesso a instantâneos para documentos com regras de acesso."
},
"DocMenu": {
"By Date Modified": "Por Data de Modificação",
@ -181,7 +182,9 @@
"Enter document name": "Digite o nome do documento",
"Remove all data but keep the structure to use as a template": "Remova todos os dados, mas mantenha a estrutura para usar como um modelo",
"Remove document history (can significantly reduce file size)": "Remova o histórico do documento (pode reduzir significativamente o tamanho do ficheiro)",
"Download full document and history": "Descarregue documento completo e histórico"
"Download full document and history": "Descarregue documento completo e histórico",
"Download": "Descarregar",
"Download document": "Descarregar documento"
},
"Pages": {
"Delete": "Apagar",
@ -219,7 +222,27 @@
"WIDGET TITLE": "TÍTULO DO WIDGET",
"Widget": "Widget",
"You do not have edit access to this document": "Não tem permissão de edição desse documento",
"Add referenced columns": "Adicionar colunas referenciadas"
"Add referenced columns": "Adicionar colunas referenciadas",
"Configuration": "Configuração",
"Default field value": "Valor padrão do campo",
"Display button": "Botão de exibição",
"Enter text": "Digite texto",
"Field title": "Título do campo",
"Layout": "Leiaute",
"Submission": "Envio",
"Submit another response": "Enviar outra resposta",
"Submit button label": "Etiqueta do botão de envio",
"Success text": "Texto de sucesso",
"Table column name": "Nome da coluna da tabela",
"Enter redirect URL": "Insira URL de redirecionamento",
"Reset form": "Restaurar formulário",
"Hidden field": "Campo escondido",
"Redirect automatically after submission": "Redirecionar automaticamente após o envio",
"No field selected": "Nenhum campo selecionado",
"Redirection": "Redirecionamento",
"Select a field in the form widget to configure.": "Selecione um campo no widget do formulário para configurar.",
"Field rules": "Regras de campo",
"Required field": "Campo necessário"
},
"ShareMenu": {
"Return to {{termToUse}}": "Retornar ao {{termToUse}}",
@ -242,7 +265,12 @@
"Original": "Original",
"Replace {{termToUse}}...": "Substituir {{termToUse}}…",
"Share": "Partilhar",
"Download...": "Descarregar..."
"Download...": "Descarregar...",
"Comma Separated Values (.csv)": "Valores separados por vírgula (.csv)",
"DOO Separated Values (.dsv)": "Valores separados por DOO (.dsv)",
"Export as...": "Exportar como...",
"Microsoft Excel (.xlsx)": "Microsoft Excel (.xlsx)",
"Tab Separated Values (.tsv)": "Valores separados por tabulação (.tsv)"
},
"SiteSwitcher": {
"Create new team site": "Criar site de equipa",
@ -318,7 +346,8 @@
"When adding table rules, automatically add a rule to grant OWNER full access.": "Ao adicionar regras de tabela, adicione automaticamente uma regra para conceder ao PROPRIETÁRIO acesso total.",
"Permission to edit document structure": "Permissão para editar a estrutura do documento",
"This default should be changed if editors' access is to be limited. ": "Esse padrão deve ser alterado se o acesso dos editores for limitado. ",
"Allow editors to edit structure (e.g. modify and delete tables, columns, layouts), and to write formulas, which give access to all data regardless of read restrictions.": "Permita que os editores editem a estrutura (por exemplo, modifiquem e excluam tabelas, colunas, layouts) e escrevam fórmulas, que dão acesso a todos os dados, independentemente das restrições de leitura."
"Allow editors to edit structure (e.g. modify and delete tables, columns, layouts), and to write formulas, which give access to all data regardless of read restrictions.": "Permita que os editores editem a estrutura (por exemplo, modifiquem e excluam tabelas, colunas, layouts) e escrevam fórmulas, que dão acesso a todos os dados, independentemente das restrições de leitura.",
"Add Table-wide Rule": "Adicionar regra para toda a tabela"
},
"ChartView": {
"Toggle chart aggregation": "Alternar a agregação de gráficos",
@ -390,7 +419,8 @@
"Insert row": "Inserir linha",
"Insert row above": "Inserir linha acima",
"Insert row below": "Inserir linha abaixo",
"View as card": "Ver como cartão"
"View as card": "Ver como cartão",
"Use as table headers": "Usar como cabeçalhos de tabela"
},
"SelectionSummary": {
"Copied to clipboard": "Copiado para a área de transferência"
@ -426,7 +456,8 @@
"TOOLS": "FERRAMENTAS",
"Tour of this Document": "Tour desse Documento",
"Validate Data": "Validar dados",
"Settings": "Configurações"
"Settings": "Configurações",
"API Console": "Consola API"
},
"TopBar": {
"Manage Team": "Gerir Equipa"
@ -574,7 +605,19 @@
"Adding UUID column": "A adicionar coluna UUID",
"Adding duplicates column": "Adicionar coluna duplicatas",
"Detect duplicates in...": "Detetar duplicados em...",
"Last updated at": "Última atualização em"
"Last updated at": "Última atualização em",
"Any": "Qualquer",
"Numeric": "Numérico",
"Integer": "Inteiro",
"Toggle": "Alternar",
"Choice": "Opção",
"DateTime": "DataHora",
"Choice List": "Lista de opções",
"Text": "Texto",
"Date": "Data",
"Reference": "Referência",
"Reference List": "Lista de referências",
"Attachment": "Anexo"
},
"HomeIntro": {
"Any documents created in this site will appear here.": "Qualquer documento criado neste site aparecerá aqui.",
@ -655,7 +698,11 @@
"Lookups return data from related tables.": "As pesquisas retornam dados de tabelas relacionadas.",
"You can choose from widgets available to you in the dropdown, or embed your own by providing its full URL.": "Pode escolher entre os widgets disponíveis no menu suspenso ou incorporar o seu próprio widget fornecendo o URL completo.",
"Can't find the right columns? Click 'Change Widget' to select the table with events data.": "Não consegue encontrar as colunas certas? Clique em \"Change Widget\" (Alterar widget) para selecionar a tabela com os dados dos eventos.",
"Use reference columns to relate data in different tables.": "Use colunas de referência para relacionar dados em diferentes tabelas."
"Use reference columns to relate data in different tables.": "Use colunas de referência para relacionar dados em diferentes tabelas.",
"Forms are here!": "Os formulários chegaram!",
"Learn more": "Saiba mais",
"Build simple forms right in Grist and share in a click with our new widget. {{learnMoreButton}}": "Crie formulários simples diretamente no Grist e partilhe-os com um clique com o nosso novo widget. {{learnMoreButton}}",
"These rules are applied after all column rules have been processed, if applicable.": "Estas regras são aplicadas após todas as regras da coluna terem sido processadas, se aplicável."
},
"WelcomeQuestions": {
"IT & Technology": "TI e Tecnologia",
@ -713,7 +760,11 @@
"You do not have access to this organization's documents.": "Não tem acesso aos documentos desta organização.",
"Account deleted{{suffix}}": "Conta excluída{{suffix}}",
"Your account has been deleted.": "A sua conta foi apagada.",
"Sign up": "Cadastre-se"
"Sign up": "Cadastre-se",
"An unknown error occurred.": "Ocorreu um erro desconhecido.",
"Build your own form": "Construa o seu próprio formulário",
"Form not found": "Formulário não encontrado",
"Powered by": "Desenvolvido por"
},
"CellStyle": {
"Cell Style": "Estilo de célula",
@ -930,7 +981,8 @@
"Document ID copied to clipboard": "ID do documento copiado para a área de transferência",
"Ok": "OK",
"Manage Webhooks": "Gerir ganchos web",
"Webhooks": "Ganchos Web"
"Webhooks": "Ganchos Web",
"API Console": "Consola API"
},
"DocumentUsage": {
"Attachments Size": "Tamanho dos Anexos",
@ -1019,7 +1071,8 @@
"Show raw data": "Mostrar dados primários",
"Widget options": "Opções do Widget",
"Add to page": "Adicionar à página",
"Collapse widget": "Colapsar widget"
"Collapse widget": "Colapsar widget",
"Create a form": "Criar um formulário"
},
"ViewSectionMenu": {
"(customized)": "(personalizado)",
@ -1063,7 +1116,17 @@
"modals": {
"Cancel": "Cancelar",
"Ok": "OK",
"Save": "Gravar"
"Save": "Gravar",
"Are you sure you want to delete this record?": "Tem certeza de que deseja apagar este registo?",
"Don't show tips": "Não mostrar dicas",
"Undo to restore": "Desfazer para restaurar",
"Don't show again.": "Não mostrar novamente.",
"Don't ask again.": "Não perguntar novamente.",
"Are you sure you want to delete these records?": "Tem a certeza de que deseja apagar esses registos?",
"Delete": "Eliminar",
"Dismiss": "Descartar",
"Got it": "Percebido",
"Don't show again": "Não mostrar novamente"
},
"pages": {
"Duplicate Page": "Duplicar a Página",
@ -1216,7 +1279,8 @@
"Opt in to Telemetry": "Aceitar a Telemetria",
"Opted In": "Optou por participar",
"Support Grist": "Suporte Grist",
"Support Grist page": "Página de Suporte Grist"
"Support Grist page": "Página de Suporte Grist",
"Admin Panel": "Painel do administrador"
},
"SupportGristPage": {
"GitHub": "GitHub",
@ -1234,7 +1298,8 @@
"You have opted out of telemetry.": "Decidiu em não participar da telemetria.",
"Support Grist": "Suporte Grist",
"Telemetry": "Telemetria",
"We only collect usage statistics, as detailed in our {{link}}, never document contents.": "Coletamos apenas estatísticas de uso, conforme detalhado no nosso {{link}}, nunca o conteúdo dos documentos."
"We only collect usage statistics, as detailed in our {{link}}, never document contents.": "Coletamos apenas estatísticas de uso, conforme detalhado no nosso {{link}}, nunca o conteúdo dos documentos.",
"Sponsor": "Patrocinador"
},
"buildViewSectionDom": {
"No data": "Sem dados",
@ -1255,5 +1320,121 @@
"Delete card": "Apagar cartão",
"Copy anchor link": "Copiar ligação de ancoragem",
"Insert card": "Inserir cartão"
},
"Menu": {
"Insert question below": "Inserir questão abaixo",
"Paragraph": "Parágrafo",
"Columns": "Colunas",
"Paste": "Colar",
"Insert question above": "Insira a questão acima",
"Header": "Cabeçalho",
"Copy": "Copiar",
"Cut": "Cortar",
"Building blocks": "Blocos de construção",
"Separator": "Separador",
"Unmapped fields": "Campos não mapeados"
},
"UnmappedFieldsConfig": {
"Map fields": "Mapear campos",
"Mapped": "Mapeado",
"Clear": "Limpar",
"Select All": "Selecionar Todos",
"Unmapped": "Desmapeado",
"Unmap fields": "Desmapear campos"
},
"FormView": {
"Are you sure you want to reset your form?": "Tem certeza de que deseja redefinir o formulário?",
"Preview": "Pré-visualização",
"Save your document to publish this form.": "Grave o seu documento para publicar este formulário.",
"Publish your form?": "Publicar o seu formulário?",
"Code copied to clipboard": "Código copiado para a área de transferência",
"Copy code": "Copiar código",
"Copy link": "Copiar ligação",
"Embed this form": "Incorporar este formulário",
"Link copied to clipboard": "Ligação copiada para a área de transferência",
"Reset form": "Redefinir formulário",
"Share": "Partilhar",
"Share this form": "Partilhe este formulário",
"View": "Ver",
"Anyone with the link below can see the empty form and submit a response.": "Qualquer pessoa com a ligação abaixo pode ver o formulário vazio e enviar uma resposta.",
"Reset": "Redefinir",
"Unpublish": "Cancelar publicação",
"Unpublish your form?": "Despublicar o seu formulário?",
"Publish": "Publicar"
},
"AdminPanel": {
"Home": "Início",
"Sponsor": "Patrocinador",
"Support Grist": "Apoiar o Grist",
"Telemetry": "Telemetria",
"Admin Panel": "Painel do administrador",
"Current": "Atual",
"Current version of Grist": "Versão atual do Grist",
"Help us make Grist better": "Ajude-nos a melhorar o Grist",
"Support Grist Labs on GitHub": "Apoie a Grist Labs no GitHub",
"Version": "Versão"
},
"Editor": {
"Delete": "Eliminar"
},
"FormConfig": {
"Field rules": "Regras de campo",
"Required field": "Campo obrigatório"
},
"CustomView": {
"To use this widget, please map all non-optional columns from the creator panel on the right.": "Para usar este widget, mapeie todas as colunas não opcionais do painel criador à direita.",
"Some required columns aren't mapped": "Algumas colunas obrigatórias não estão mapeadas"
},
"FormContainer": {
"Build your own form": "Crie o seu próprio formulário",
"Powered by": "Desenvolvido por"
},
"FormModel": {
"Oops! The form you're looking for doesn't exist.": "Epá! O formulário que procura não existe.",
"Oops! This form is no longer published.": "Ops! Este formulário não está mais publicado.",
"There was a problem loading the form.": "Houve um problema ao carregar o formulário.",
"You don't have access to this form.": "Não tem acesso a este formulário."
},
"FormSuccessPage": {
"Thank you! Your response has been recorded.": "Obrigado! A sua resposta foi registada.",
"Form Submitted": "Formulário enviado"
},
"DateRangeOptions": {
"Last 30 days": "Últimos 30 dias",
"This month": "Este mês",
"This week": "Esta semana",
"Last 7 days": "Últimos 7 dias",
"Last Week": "Semana passada",
"Next 7 days": "Próximo 7 dias",
"This year": "Este ano",
"Today": "Hoje"
},
"FormErrorPage": {
"Error": "Erro"
},
"FormPage": {
"There was an error submitting your form. Please try again.": "Houve um erro ao enviar o seu formulário. Por favor, tente novamente."
},
"MappedFieldsConfig": {
"Clear": "Limpar",
"Map fields": "Mapear campos",
"Mapped": "Mapeado",
"Select All": "Selecionar tudo",
"Unmapped": "Desmapeado",
"Unmap fields": "Desmapear campos"
},
"Section": {
"Insert section above": "Inserir secção acima",
"Insert section below": "Inserir secção abaixo"
},
"WelcomeCoachingCall": {
"free coaching call": "chamada gratuita de treinamento",
"Schedule Call": "Agendar chamada",
"Maybe Later": "Talvez mais tarde",
"On the call, we'll take the time to understand your needs and tailor the call to you. We can show you the Grist basics, or start working with your data right away to build the dashboards you need.": "Na chamada, vamos ter tempo para perceber as suas necessidades e adaptar a chamada para si. Podemos mostrar-lhe os princípios básicos do Grist ou começar a trabalhar com os seus dados imediatamente para construir os painéis que precisa.",
"Schedule your {{freeCoachingCall}} with a member of our team.": "Programe o seu {{freeCoachingCall}} com um membro da nossa equipa."
},
"HiddenQuestionConfig": {
"Hidden fields": "Campos ocultos"
}
}

@ -233,7 +233,8 @@
"Compare to Previous": "Comparar ao anterior",
"Open Snapshot": "Abrir Instantâneo",
"Snapshots": "Instantâneos",
"Snapshots are unavailable.": "Os instantâneos não estão disponíveis."
"Snapshots are unavailable.": "Os instantâneos não estão disponíveis.",
"Only owners have access to snapshots for documents with access rules.": "Apenas os proprietários têm acesso a instantâneos para documentos com regras de acesso."
},
"DocMenu": {
"(The organization needs a paid plan)": "(A organização precisa de um plano pago)",
@ -700,7 +701,9 @@
"Redirect automatically after submission": "Redirecionar automaticamente após o envio",
"Configuration": "Configuração",
"Success text": "Texto de sucesso",
"Layout": "Leiaute"
"Layout": "Leiaute",
"No field selected": "Nenhum campo selecionado",
"Select a field in the form widget to configure.": "Selecione um campo no widget do formulário para configurar."
},
"RowContextMenu": {
"Copy anchor link": "Copiar o link de ancoragem",
@ -712,7 +715,8 @@
"Insert row below": "Inserir linha abaixo",
"Duplicate rows_one": "Duplicar linha",
"Duplicate rows_other": "Duplicar linhas",
"View as card": "Ver como cartão"
"View as card": "Ver como cartão",
"Use as table headers": "Usar como cabeçalhos de tabela"
},
"SelectionSummary": {
"Copied to clipboard": "Copiado para a área de transferência"
@ -738,7 +742,12 @@
"Unsaved": "Não Salvo",
"Work on a Copy": "Trabalho em uma cópia",
"Share": "Compartilhar",
"Download...": "Baixar..."
"Download...": "Baixar...",
"Tab Separated Values (.tsv)": "Valores separados por tabulação (.tsv)",
"Export as...": "Exportar como...",
"Microsoft Excel (.xlsx)": "Microsoft Excel (.xlsx)",
"Comma Separated Values (.csv)": "Valores separados por vírgula (.csv)",
"DOO Separated Values (.dsv)": "Valores separados por DOO (.dsv)"
},
"SiteSwitcher": {
"Create new team site": "Criar novo site de equipe",
@ -1279,7 +1288,8 @@
"Support Grist": "Suporte Grist",
"Contribute": "Contribuir",
"Opted In": "Optou por participar",
"Support Grist page": "Página de Suporte Grist"
"Support Grist page": "Página de Suporte Grist",
"Admin Panel": "Painel do administrador"
},
"SupportGristPage": {
"GitHub": "GitHub",
@ -1297,7 +1307,8 @@
"You have opted out of telemetry.": "Você decidiu em não participar da telemetria.",
"We only collect usage statistics, as detailed in our {{link}}, never document contents.": "Coletamos apenas estatísticas de uso, conforme detalhado em nosso {{link}}, nunca o conteúdo dos documentos.",
"This instance is opted out of telemetry. Only the site administrator has permission to change this.": "Esta instância foi desativada da telemetria. Somente o administrador do site tem permissão para alterar isso.",
"You have opted in to telemetry. Thank you!": "Você optou pela telemetria. Obrigado!"
"You have opted in to telemetry. Thank you!": "Você optou pela telemetria. Obrigado!",
"Sponsor": "Patrocinador"
},
"buildViewSectionDom": {
"No data": "Sem dados",
@ -1381,7 +1392,21 @@
"Publish": "Publicar",
"Publish your form?": "Publicar o seu formulário?",
"Unpublish your form?": "Despublicar seu formulário?",
"Unpublish": "Cancelar publicação"
"Unpublish": "Cancelar publicação",
"Are you sure you want to reset your form?": "Tem certeza de que deseja redefinir o formulário?",
"Embed this form": "Incorporar este formulário",
"Link copied to clipboard": "Link copiado para a área de transferência",
"Reset form": "Redefinir formulário",
"Share this form": "Compartilhe este formulário",
"View": "Ver",
"Anyone with the link below can see the empty form and submit a response.": "Qualquer pessoa com o link abaixo pode ver o formulário vazio e enviar uma resposta.",
"Copy link": "Copiar link",
"Reset": "Redefinir",
"Save your document to publish this form.": "Salve seu documento para publicar esse formulário.",
"Share": "Compartilhar",
"Code copied to clipboard": "Código copiado para a área de transferência",
"Copy code": "Copiar código",
"Preview": "Pré-visualização"
},
"Menu": {
"Columns": "Colunas",
@ -1451,5 +1476,29 @@
"This week": "Esta semana",
"This year": "Este ano",
"Today": "Hoje"
},
"MappedFieldsConfig": {
"Select All": "Selecionar tudo",
"Unmap fields": "Desmapear campos",
"Unmapped": "Desmapeado",
"Clear": "Limpar",
"Map fields": "Mapear campos",
"Mapped": "Mapeado"
},
"Section": {
"Insert section above": "Inserir seção acima",
"Insert section below": "Inserir seção abaixo"
},
"AdminPanel": {
"Current": "Atual",
"Current version of Grist": "Versão atual do Grist",
"Help us make Grist better": "Ajude-nos a melhorar o Grist",
"Support Grist Labs on GitHub": "Apoie a Grist Labs no GitHub",
"Admin Panel": "Painel do administrador",
"Home": "Início",
"Sponsor": "Patrocinador",
"Support Grist": "Apoiar o Grist",
"Telemetry": "Telemetria",
"Version": "Versão"
}
}

@ -358,7 +358,8 @@
"Beta": "Beta",
"Compare to Current": "Сравните с текущим",
"Snapshots are unavailable.": "Снимки недоступны.",
"Open Snapshot": "Открыть Снимок"
"Open Snapshot": "Открыть Снимок",
"Only owners have access to snapshots for documents with access rules.": "Только владельцы имеют доступ к снимкам документов с правилами доступа."
},
"DocMenu": {
"By Name": "По имени",
@ -1268,7 +1269,8 @@
"Support Grist": "Поддержка Grist",
"Support Grist page": "Страница поддержки Grist",
"Contribute": "Участвовать",
"Opted In": "Подключено"
"Opted In": "Подключено",
"Admin Panel": "Панель администратора"
},
"SupportGristPage": {
"GitHub": "GitHub",
@ -1286,7 +1288,8 @@
"Opt out of Telemetry": "Отказаться от телеметрии",
"Sponsor Grist Labs on GitHub": "Спонсор Grist Labs на GitHub",
"This instance is opted in to telemetry. Only the site administrator has permission to change this.": "В этом экземпляре включена телеметрия. Только администратор сайта может изменить это.",
"We only collect usage statistics, as detailed in our {{link}}, never document contents.": "Мы собираем только статистику использования, как описано в нашей {{link}}, никогда содержимое документов."
"We only collect usage statistics, as detailed in our {{link}}, never document contents.": "Мы собираем только статистику использования, как описано в нашей {{link}}, никогда содержимое документов.",
"Sponsor": "Спонсор"
},
"buildViewSectionDom": {
"No data": "Нет данных",
@ -1421,5 +1424,17 @@
"Section": {
"Insert section above": "Вставить секцию выше",
"Insert section below": "Вставить секцию ниже"
},
"AdminPanel": {
"Current": "Текущий",
"Current version of Grist": "Текущая версия Grist",
"Help us make Grist better": "Помогите нам сделать Grist лучше",
"Home": "Домой",
"Sponsor": "Спонсор",
"Support Grist": "Поддержать Grist",
"Support Grist Labs on GitHub": "Поддержите Grist Labs на GitHub",
"Version": "Версия",
"Admin Panel": "Панель администратора",
"Telemetry": "Телеметрия"
}
}

@ -412,7 +412,8 @@
"Compare to Previous": "Primerjava s prejšnjimi",
"Snapshots": "Posnetki",
"Snapshots are unavailable.": "Posnetki niso na voljo.",
"Open Snapshot": "Odpri posnetek stanja"
"Open Snapshot": "Odpri posnetek stanja",
"Only owners have access to snapshots for documents with access rules.": "Samo lastniki imajo dostop do posnetkov za dokumente s pravili dostopa."
},
"ExampleInfo": {
"Check out our related tutorial for how to link data, and create high-productivity layouts.": "Oglejte si sorodno navodilo za povezovanje podatkov in ustvarjanje visokoproduktivnih postavitev.",
@ -679,7 +680,8 @@
"Opt in to Telemetry": "Prijava na telemetrijo",
"You have opted in to telemetry. Thank you!": "Prijavili ste se za telemetrijo. Zahvaljujemo se vam!",
"This instance is opted in to telemetry. Only the site administrator has permission to change this.": "Ta primerek je prijavljen v telemetrijo. To lahko spremeni le skrbnik spletnega mesta.",
"GitHub": "GitHub"
"GitHub": "GitHub",
"Sponsor": "Sponzor"
},
"GristTooltips": {
"Updates every 5 minutes.": "Posodablja se vsakih 5 minut.",
@ -845,7 +847,8 @@
"Help Center": "Center za pomoč",
"Contribute": "Prispevajte",
"Support Grist page": "Grist podpora",
"Opted In": "Prijavljeno"
"Opted In": "Prijavljeno",
"Admin Panel": "Skrbniški panel"
},
"HomeIntro": {
"personal site": "osebna stran",
@ -995,7 +998,21 @@
},
"WebhookPage": {
"Clear Queue": "Počisti čakalno vrsto",
"Webhook Settings": "Nastavitve Webhook"
"Webhook Settings": "Nastavitve Webhook",
"Enabled": "Omogočeno",
"Memo": "Beležka",
"Name": "Ime",
"Ready Column": "Pripravljen stolpec",
"Removed webhook.": "Odstranjen webhook.",
"Sorry, not all fields can be edited.": "Žal vseh polj ni mogoče urejati.",
"Status": "Status",
"Table": "Tabela",
"Filter for changes in these columns (semicolon-separated ids)": "Filter za spremembe v teh stolpcih (id-ji, ločeni s podpičjem)",
"Cleared webhook queue.": "Čakalna vrsta webhook je počiščena.",
"URL": "URL",
"Webhook Id": "Webhook ID",
"Columns to check when update (separated by ;)": "Stolpci za preverjanje ob posodobitvi (ločeni z ;)",
"Event Types": "Vrste dogodkov"
},
"RecordLayout": {
"Updating record layout.": "Posodobitev postavitve zapisa."
@ -1421,5 +1438,17 @@
"Section": {
"Insert section above": "Vstavi razdelek zgoraj",
"Insert section below": "Vstavite razdelek spodaj"
},
"AdminPanel": {
"Help us make Grist better": "Pomagaj nam izboljšati Grist",
"Home": "Domov",
"Admin Panel": "Skrbniški panel",
"Current": "Trenutno",
"Current version of Grist": "Trenutna različica Grista",
"Sponsor": "Sponzor",
"Support Grist": "Podpora Gristu",
"Telemetry": "Telemetrija",
"Support Grist Labs on GitHub": "Podpri Grist Labs na GitHubu",
"Version": "Verzija"
}
}

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;
class RedisClient {
public readonly connected: boolean;
public eval(args: any[], callback?: (err: Error | null, res: any) => void): any;
public subscribe(channel: string): void;

@ -46,7 +46,7 @@ describe('SafeBrowser', function() {
browserProcesses = [];
sandbox.stub(SafeBrowser, 'createWorker').callsFake(createProcess);
sandbox.stub(SafeBrowser, 'createView').callsFake(createProcess);
sandbox.stub(SafeBrowser, 'createView').callsFake(createProcess as any);
sandbox.stub(PluginInstance.prototype, 'getRenderTarget').returns(noop);
disposeSpy = sandbox.spy(Disposable.prototype, 'dispose');
});

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

@ -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 path from 'path';
// We only support those formats for now:
// en.client.json
// en_US.client.json
// en_US.server.json
// zh_Hant.client.json
// {lang code (+ maybe with underscore and country code}.{namespace}.json
//
// Only this format was tested and is known to work.
const VALID_LOCALE_FORMAT = /^[a-z]{2,}(_\w+)?\.(\w+)\.json$/;
describe("Localization", function() {
this.timeout(60000);
setupTestSuite();
@ -40,20 +52,20 @@ describe("Localization", function() {
const langs: Set<string> = new Set();
const namespaces: Set<string> = new Set();
for (const file of fs.readdirSync(localeDirectory)) {
if (file.endsWith(".json")) {
const langRaw = file.split('.')[0];
const lang = langRaw?.replace(/_/g, '-');
const ns = file.split('.')[1];
const clientFile = path.join(localeDirectory,
`${langRaw}.client.json`);
const clientText = fs.readFileSync(clientFile, { encoding: 'utf8' });
if (!clientText.includes('Translators: please translate this only when')) {
// Translation not ready if this key is not present.
continue;
}
langs.add(lang);
namespaces.add(ns);
// Make sure we see only valid files.
assert.match(file, VALID_LOCALE_FORMAT);
const langRaw = file.split('.')[0];
const lang = langRaw?.replace(/_/g, '-');
const ns = file.split('.')[1];
const clientFile = path.join(localeDirectory,
`${langRaw}.client.json`);
const clientText = fs.readFileSync(clientFile, { encoding: 'utf8' });
if (!clientText.includes('Translators: please translate this only when')) {
// Translation not ready if this key is not present.
continue;
}
langs.add(lang);
namespaces.add(ns);
}
assert.deepEqual(gristConfig.supportedLngs.sort(), [...langs].sort());
assert.deepEqual(gristConfig.namespaces.sort(), [...namespaces].sort());
@ -90,6 +102,8 @@ describe("Localization", function() {
const enResponse = await (await fetch(homeUrl)).text();
const uzResponse = await (await fetch(homeUrl, {headers: {"Accept-Language": "uz-UZ,uz;q=1"}})).text();
const ptResponse = await (await fetch(homeUrl, {headers: {"Accept-Language": "pt-PR,pt;q=1"}})).text();
// We have file with nb_NO code, but still this should be preloaded.
const noResponse = await (await fetch(homeUrl, {headers: {"Accept-Language": "nb-NO,nb;q=1"}})).text();
function present(response: string, ...langs: string[]) {
for (const lang of langs) {
@ -107,6 +121,7 @@ describe("Localization", function() {
present(enResponse, "en");
present(uzResponse, "en");
present(ptResponse, "en");
present(noResponse, "en");
// Other locales are not preloaded for English.
notPresent(enResponse, "uz", "un-UZ", "en-US");
@ -117,6 +132,9 @@ describe("Localization", function() {
notPresent(uzResponse, "uz-UZ");
notPresent(ptResponse, "pt-PR", "uz", "en-US");
// For no-NO we have nb_NO file.
present(noResponse, "nb_NO");
});
it("loads correct languages from file system", async function() {

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

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

@ -67,6 +67,9 @@ export class TestServerMerged extends EventEmitter implements IMochaServer {
this._starts++;
const workerIdText = process.env.MOCHA_WORKER_ID || '0';
if (reset) {
// Make sure this test server doesn't keep using the DB that's about to disappear.
await this.closeDatabase();
if (process.env.TESTDIR) {
this.testDir = path.join(process.env.TESTDIR, workerIdText);
} else {

@ -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
// situation where 'close' and 'failedSend' events happen in either order.
const stubOnClose = sandbox.stub(Client.prototype as any, '_onClose')
.callsFake(async function(this: Client) {
if (!options.closeHappensFirst) { await delay(10); }
const stubOnClose: any = sandbox.stub(Client.prototype as any, '_onClose')
.callsFake(function(this: Client) {
eventsSeen.push('close');
return (stubOnClose as any).wrappedMethod.apply(this, arguments);
return stubOnClose.wrappedMethod.apply(this, arguments);
});
// Intercept calls to client.sendMessage(), to know when it fails, and possibly to delay the
// failures to hit a particular order in which 'close' and 'failedSend' events are seen by
// Client.ts. This is the only reliable way I found to reproduce this order of events.
const stubSendToWebsocket = sandbox.stub(Client.prototype as any, '_sendToWebsocket')
const stubSendToWebsocket: any = sandbox.stub(Client.prototype as any, '_sendToWebsocket')
.callsFake(async function(this: Client) {
try {
return await (stubSendToWebsocket as any).wrappedMethod.apply(this, arguments);
return await stubSendToWebsocket.wrappedMethod.apply(this, arguments);
} catch (err) {
if (options.closeHappensFirst) { await delay(100); }
eventsSeen.push('failedSend');

@ -3347,13 +3347,21 @@ function testDocApi() {
});
describe('webhooks related endpoints', async function () {
/*
Regression test for old _subscribe endpoint. /docs/{did}/webhooks should be used instead to subscribe
*/
async function oldSubscribeCheck(requestBody: any, status: number, ...errors: RegExp[]) {
const resp = await axios.post(
`${serverUrl}/api/docs/${docIds.Timesheets}/tables/Table1/_subscribe`,
requestBody, chimpy
const serving: Serving = await serveSomething(app => {
app.use(express.json());
app.post('/200', ({body}, res) => {
res.sendStatus(200);
res.end();
});
}, webhooksTestPort);
/*
Regression test for old _subscribe endpoint. /docs/{did}/webhooks should be used instead to subscribe
*/
async function oldSubscribeCheck(requestBody: any, status: number, ...errors: RegExp[]) {
const resp = await axios.post(
`${serverUrl}/api/docs/${docIds.Timesheets}/tables/Table1/_subscribe`,
requestBody, chimpy
);
assert.equal(resp.status, status);
for (const error of errors) {
@ -3430,7 +3438,15 @@ function testDocApi() {
await postWebhookCheck({webhooks:[{fields: {eventTypes: ["add"], url: "https://example.com"}}]},
400, /tableId is missing/);
await postWebhookCheck({}, 400, /webhooks is missing/);
await postWebhookCheck({
webhooks: [{
fields: {
tableId: "Table1", eventTypes: ["update"], watchedColIds: ["notExisting"],
url: `${serving.url}/200`
}
}]
},
403, /Column not found notExisting/);
});
@ -3855,6 +3871,7 @@ function testDocApi() {
tableId?: string,
isReadyColumn?: string | null,
eventTypes?: string[]
watchedColIds?: string[],
}) {
// Subscribe helper that returns a method to unsubscribe.
const data = await subscribe(endpoint, docId, options);
@ -3872,6 +3889,7 @@ function testDocApi() {
tableId?: string,
isReadyColumn?: string|null,
eventTypes?: string[],
watchedColIds?: string[],
name?: string,
memo?: string,
enabled?: boolean,
@ -3883,7 +3901,7 @@ function testDocApi() {
eventTypes: options?.eventTypes ?? ['add', 'update'],
url: `${serving.url}/${endpoint}`,
isReadyColumn: options?.isReadyColumn === undefined ? 'B' : options?.isReadyColumn,
...pick(options, 'name', 'memo', 'enabled'),
...pick(options, 'name', 'memo', 'enabled', 'watchedColIds'),
}, chimpy
);
assert.equal(status, 200);
@ -4407,6 +4425,72 @@ function testDocApi() {
await webhook1();
});
it("should call to a webhook only when columns updated are in watchedColIds if not empty", async () => { // eslint-disable-line max-len
// Create a test document.
const ws1 = (await userApi.getOrgWorkspaces('current'))[0].id;
const docId = await userApi.newDoc({ name: 'testdoc5' }, ws1);
const doc = userApi.getDocAPI(docId);
await axios.post(`${serverUrl}/api/docs/${docId}/apply`, [
['ModifyColumn', 'Table1', 'B', { type: 'Bool' }],
], chimpy);
const modifyColumn = async (newValues: { [key: string]: any; } ) => {
await axios.post(`${serverUrl}/api/docs/${docId}/apply`, [
['UpdateRecord', 'Table1', newRowIds[0], newValues],
], chimpy);
await delay(100);
};
const assertSuccessNotCalled = async () => {
assert.isFalse(successCalled.called());
successCalled.reset();
};
const assertSuccessCalled = async () => {
assert.isTrue(successCalled.called());
await successCalled.waitAndReset();
};
// Webhook with only one watchedColId.
const webhook1 = await autoSubscribe('200', docId, {
watchedColIds: ['A'], eventTypes: ['add', 'update']
});
successCalled.reset();
// Create record, that will call the webhook.
const newRowIds = await doc.addRows("Table1", {
A: [2],
B: [true],
C: ['c1']
});
await delay(100);
assert.isTrue(successCalled.called());
await successCalled.waitAndReset();
await modifyColumn({ C: 'c2' });
await assertSuccessNotCalled();
await modifyColumn({ A: 19 });
await assertSuccessCalled();
await webhook1(); // Unsubscribe.
// Webhook with multiple watchedColIds
const webhook2 = await autoSubscribe('200', docId, {
watchedColIds: ['A', 'B'], eventTypes: ['update']
});
successCalled.reset();
await modifyColumn({ C: 'c3' });
await assertSuccessNotCalled();
await modifyColumn({ A: 20 });
await assertSuccessCalled();
await webhook2();
// Check that empty string in watchedColIds are ignored
const webhook3 = await autoSubscribe('200', docId, {
watchedColIds: ['A', ""], eventTypes: ['update']
});
await modifyColumn({ C: 'c4' });
await assertSuccessNotCalled();
await modifyColumn({ A: 21 });
await assertSuccessCalled();
await webhook3();
});
it("should return statistics", async () => {
await clearQueue(docId);
// Read stats, it should be empty.
@ -4427,6 +4511,7 @@ function testDocApi() {
tableId: 'Table1',
name: '',
memo: '',
watchedColIds: [],
}, usage : {
status: 'idle',
numWaiting: 0,
@ -4444,6 +4529,7 @@ function testDocApi() {
tableId: 'Table1',
name: '',
memo: '',
watchedColIds: [],
}, usage : {
status: 'idle',
numWaiting: 0,
@ -4775,42 +4861,53 @@ function testDocApi() {
describe('webhook update', function () {
it('should work correctly', async function () {
async function check(fields: any, status: number, error?: RegExp | string,
expectedFieldsCallback?: (fields: any) => any) {
let savedTableId = 'Table1';
const origFields = {
tableId: 'Table1',
eventTypes: ['add'],
isReadyColumn: 'B',
name: 'My Webhook',
memo: 'Sync store',
watchedColIds: ['A']
};
// subscribe
const webhook = await subscribe('foo', docId, origFields);
const {data} = await axios.post(
`${serverUrl}/api/docs/${docId}/webhooks`,
{
webhooks: [{
fields: {
...origFields,
url: `${serving.url}/foo`
}
}]
}, chimpy
);
const webhooks = data;
const expectedFields = {
url: `${serving.url}/foo`,
unsubscribeKey: webhook.unsubscribeKey,
eventTypes: ['add'],
isReadyColumn: 'B',
tableId: 'Table1',
enabled: true,
name: 'My Webhook',
memo: 'Sync store',
watchedColIds: ['A'],
};
let stats = await readStats(docId);
assert.equal(stats.length, 1, 'stats=' + JSON.stringify(stats));
assert.equal(stats[0].id, webhook.webhookId);
assert.deepEqual(stats[0].fields, expectedFields);
assert.equal(stats[0].id, webhooks.webhooks[0].id);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const {unsubscribeKey, ...fieldsWithoutUnsubscribeKey} = stats[0].fields;
assert.deepEqual(fieldsWithoutUnsubscribeKey, expectedFields);
// update
const resp = await axios.patch(
`${serverUrl}/api/docs/${docId}/webhooks/${webhook.webhookId}`, fields, chimpy
`${serverUrl}/api/docs/${docId}/webhooks/${webhooks.webhooks[0].id}`, fields, chimpy
);
// check resp
@ -4818,14 +4915,13 @@ function testDocApi() {
if (resp.status === 200) {
stats = await readStats(docId);
assert.equal(stats.length, 1);
assert.equal(stats[0].id, webhook.webhookId);
assert.equal(stats[0].id, webhooks.webhooks[0].id);
if (expectedFieldsCallback) {
expectedFieldsCallback(expectedFields);
}
assert.deepEqual(stats[0].fields, {...expectedFields, ...fields});
if (fields.tableId) {
savedTableId = fields.tableId;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const {unsubscribeKey, ...fieldsWithoutUnsubscribeKey} = stats[0].fields;
assert.deepEqual(fieldsWithoutUnsubscribeKey, { ...expectedFields, ...fields });
} else {
if (error instanceof RegExp) {
assert.match(resp.data.details?.userError || resp.data.error, error);
@ -4835,7 +4931,9 @@ function testDocApi() {
}
// finally unsubscribe
const unsubscribeResp = await unsubscribe(docId, webhook, savedTableId);
const unsubscribeResp = await axios.delete(
`${serverUrl}/api/docs/${docId}/webhooks/${webhooks.webhooks[0].id}`, chimpy
);
assert.equal(unsubscribeResp.status, 200, JSON.stringify(pick(unsubscribeResp, ['data', 'status'])));
stats = await readStats(docId);
assert.equal(stats.length, 0, 'stats=' + JSON.stringify(stats));
@ -4846,11 +4944,13 @@ function testDocApi() {
await check({url: "http://example.com"}, 403, "Provided url is forbidden"); // not https
// changing table without changing the ready column should reset the latter
await check({tableId: 'Table2'}, 200, '', expectedFields => expectedFields.isReadyColumn = null);
await check({tableId: 'Table2'}, 200, '', expectedFields => {
expectedFields.isReadyColumn = null;
expectedFields.watchedColIds = [];
});
await check({tableId: 'Santa'}, 404, `Table not found "Santa"`);
await check({tableId: 'Table2', isReadyColumn: 'Foo'}, 200);
await check({tableId: 'Table2', isReadyColumn: 'Foo', watchedColIds: []}, 200);
await check({eventTypes: ['add', 'update']}, 200);
await check({eventTypes: []}, 400, "eventTypes must be a non-empty array");

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

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

Loading…
Cancel
Save