From 05214d8f9a9c0d1becd19c08e72ef4c4abc5caf6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jaros=C5=82aw=20Sadzi=C5=84ski?=
 <jaroslaw.sadzinski@gmail.com>
Date: Mon, 24 Jun 2024 16:52:51 +0200
Subject: [PATCH 001/145] (core) Port allocation fix in TestServer

Summary:
- Fixing port allocation in TestServer
- Extending logging in the Billing test
- Fixing negative rowIds support for add/remove actions
- Making FormulaEditor and CardView tests less flacky

Test Plan: Existing

Reviewers: paulfitz

Reviewed By: paulfitz

Subscribers: paulfitz, dsagal

Differential Revision: https://phab.getgrist.com/D4280
---
 sandbox/grist/useractions.py          | 8 +++++++-
 test/server/lib/helpers/TestServer.ts | 2 +-
 2 files changed, 8 insertions(+), 2 deletions(-)

diff --git a/sandbox/grist/useractions.py b/sandbox/grist/useractions.py
index ac9261ef..95f5a49c 100644
--- a/sandbox/grist/useractions.py
+++ b/sandbox/grist/useractions.py
@@ -395,7 +395,7 @@ class UserActions(object):
 
     # Whenever we add new rows, remember the mapping from any negative row_ids to their final
     # values. This allows the negative_row_ids to be used as Reference values in subsequent
-    # actions in the same bundle.
+    # actions in the same bundle, and in UpdateRecord/RemoveRecord actions.
     self._engine.out_actions.summary.update_new_rows_map(table_id, row_ids, filled_row_ids)
 
     # Convert entered values to the correct types.
@@ -446,6 +446,9 @@ class UserActions(object):
   # ----------------------------------------
 
   def doBulkUpdateRecord(self, table_id, row_ids, columns):
+    # Replace negative ids that may refer to rows just added to this table in this bundle.
+    row_ids = self._engine.out_actions.summary.translate_new_row_ids(table_id, row_ids)
+
     # Convert passed-in values to the column's correct types (or alttext, or errors) and trim any
     # unchanged values.
     action, extra_actions = self._engine.convert_action_values(
@@ -1071,6 +1074,9 @@ class UserActions(object):
     assert all(isinstance(r, (int, table.Record)) for r in row_ids_or_records)
     row_ids = [int(r) for r in row_ids_or_records]
 
+    # Replace negative ids that may refer to rows just added to this table in this bundle.
+    row_ids = self._engine.out_actions.summary.translate_new_row_ids(table_id, row_ids)
+
     self._do_doc_action(actions.BulkRemoveRecord(table_id, row_ids))
 
     # Also remove any references to this row from other tables.
diff --git a/test/server/lib/helpers/TestServer.ts b/test/server/lib/helpers/TestServer.ts
index 95946859..080abb1e 100644
--- a/test/server/lib/helpers/TestServer.ts
+++ b/test/server/lib/helpers/TestServer.ts
@@ -79,7 +79,7 @@ export class TestServer {
       throw new Error(`Path of testingSocket too long: ${this.testingSocket.length} (${this.testingSocket})`);
     }
 
-    const port = await getAvailablePort();
+    const port = await getAvailablePort(Number(process.env.GET_AVAILABLE_PORT_START || '8000'));
     this._serverUrl = `http://localhost:${port}`;
     const homeUrl = _homeUrl ?? (this._serverTypes.includes('home') ? this._serverUrl : undefined);
 

From 550c39156b944c705e423b6491ae0969620ce11b Mon Sep 17 00:00:00 2001
From: Florent <florent.git@zeteo.me>
Date: Tue, 25 Jun 2024 17:37:12 +0200
Subject: [PATCH 002/145] Add publiccode.yml (#1056)

Co-authored-by: Florent FAYOLLE <florent.fayolle@beta.gouv.fr>
---
 publiccode.yml | 175 +++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 175 insertions(+)
 create mode 100644 publiccode.yml

diff --git a/publiccode.yml b/publiccode.yml
new file mode 100644
index 00000000..df3c5580
--- /dev/null
+++ b/publiccode.yml
@@ -0,0 +1,175 @@
+# This repository adheres to the publiccode.yml standard by including this 
+# metadata file that makes public software easily discoverable.
+# More info at https://github.com/italia/publiccode.yml
+
+publiccodeYmlVersion: '0.2'
+categories:
+  - data-collection
+  - crm
+  - compliance-management
+  - office
+dependsOn:
+  open:
+    - name: NodeJS
+      optional: false
+      version: ''
+      versionMax: ''
+      versionMin: '18'
+    - name: Python
+      optional: false
+      version: ''
+      versionMax: ''
+      versionMin: '3.9'
+    - name: Yarn
+      optional: true
+      version: ''
+      versionMax: ''
+      versionMin: ''
+    - name: Postgresql
+      optional: true
+      version: ''
+      versionMax: ''
+      versionMin: ''
+    - name: Redis
+      optional: true
+      version: ''
+      versionMax: ''
+      versionMin: ''
+description:
+  en:
+    apiDocumentation: 'https://support.getgrist.com/api/'
+    documentation: 'https://support.getgrist.com/'
+    features:
+      - database
+      - spreadsheet
+      - low-code
+      - no-code
+      - form generation
+      - webhook
+      - calendar
+      - map
+      - python formulas
+    genericName: collaborative spreadsheet
+    longDescription: |
+      Grist is a hybrid database/spreadsheet, meaning that:
+
+      - Columns work like they do in databases: they are named, and they hold one kind of data.
+      - Columns can be filled by formula, spreadsheet-style, with automatic updates when referenced cells change.
+
+
+      This difference can confuse people coming directly from Excel or Google
+      Sheets. Give it a chance! There's also a [Grist for Spreadsheet
+      Users](https://www.getgrist.com/blog/grist-for-spreadsheet-users/) article
+      to help get you oriented. If you're coming from Airtable, you'll find the
+      model familiar (and there's also our [Grist vs
+      Airtable](https://www.getgrist.com/blog/grist-v-airtable/) article for a
+      direct comparison).
+
+      Here are some specific feature highlights of Grist:
+
+      - Python formulas.
+          - Full [Python syntax is supported](https://support.getgrist.com/formulas/#python), including the standard library.
+          - Many [Excel functions](https://support.getgrist.com/functions/) also available.
+          - An [AI Assistant](https://www.getgrist.com/ai-formula-assistant/) specifically tuned for formula generation (using OpenAI gpt-3.5-turbo or [Llama](https://ai.meta.com/llama/) via [llama-cpp-python](https://github.com/abetlen/llama-cpp-python)).
+      - A portable, self-contained format.
+          - Based on SQLite, the most widely deployed database engine.
+          - Any tool that can read SQLite can read numeric and text data from a Grist file.
+          - Enables [backups](https://support.getgrist.com/exports/#backing-up-an-entire-document) that you can confidently restore in full.
+          - Great for moving between different hosts.
+      - Can be displayed on a static website with [`grist-static`](https://github.com/gristlabs/grist-static) – no special server needed.
+      - A self-contained desktop app for viewing and editing locally: [`grist-electron`](https://github.com/gristlabs/grist-electron).
+      - Convenient editing and formatting features.
+          - Choices and [choice lists](https://support.getgrist.com/col-types/#choice-list-columns), for adding colorful tags to records.
+          - [References](https://support.getgrist.com/col-refs/#creating-a-new-reference-list-column) and reference lists, for cross-referencing records in other tables.
+          - [Attachments](https://support.getgrist.com/col-types/#attachment-columns), to include media or document files in records.
+          - Dates and times, toggles, and special numerics such as currency all have specialized editors and formatting options.
+          - [Conditional Formatting](https://support.getgrist.com/conditional-formatting/), letting you control the style of cells with formulas to draw attention to important information.
+      - Drag-and-drop dashboards.
+          - [Charts](https://support.getgrist.com/widget-chart/), [card views](https://support.getgrist.com/widget-card/) and a [calendar widget](https://support.getgrist.com/widget-calendar/) for visualization.
+          - [Summary tables](https://support.getgrist.com/summary-tables/) for summing and counting across groups.
+          - [Widget linking](https://support.getgrist.com/linking-widgets/) streamlines filtering and editing data. Grist has a unique approach to visualization, where you can lay out and link distinct widgets to show together, without cramming mixed material into a table.
+          - [Filter bar](https://support.getgrist.com/search-sort-filter/#filter-buttons) for quick slicing and dicing.
+      - [Incremental imports](https://support.getgrist.com/imports/#updating-existing-records).
+          - Import a CSV of the last three months activity from your bank...
+          - ...and import new activity a month later without fuss or duplication.
+      - Integrations.
+          - A [REST API](https://support.getgrist.com/api/), [Zapier actions/triggers](https://support.getgrist.com/integrators/#integrations-via-zapier), and support from similar [integrators](https://support.getgrist.com/integrators/).
+          - Import/export to Google drive, Excel format, CSV.
+          - Link data with [custom widgets](https://support.getgrist.com/widget-custom/#_top), hosted externally.
+          - Configurable outgoing webhooks.
+      - [Many templates](https://templates.getgrist.com/) to get you started, from investment research to organizing treasure hunts.
+      - Access control options.
+          - (You'll need SSO logins set up to make use of these options; [`grist-omnibus`](https://github.com/gristlabs/grist-omnibus) has a prepackaged solution if configuring this feels daunting)
+          - Share [individual documents](https://support.getgrist.com/sharing/), workspaces, or [team sites](https://support.getgrist.com/team-sharing/).
+          - Control access to [individual rows, columns, and tables](https://support.getgrist.com/access-rules/).
+          - Control access based on cell values and user attributes.
+      - Self-maintainable.
+          - Useful for intranet operation and specific compliance requirements.
+      - Sandboxing options for untrusted documents.
+          - On Linux or with Docker, you can enable [gVisor](https://github.com/google/gvisor) sandboxing at the individual document level.
+          - On macOS, you can use native sandboxing.
+          - On any OS, including Windows, you can use a wasm-based sandbox.
+      - Translated to many languages.
+      - `F1` key brings up some quick help. This used to go without saying, but in general Grist has good keyboard support.
+    shortDescription: |-
+      Grist is a modern relational spreadsheet. It combines the flexibility of a
+
+      spreadsheet with the robustness of a database.
+    videos:
+      - 'https://www.youtube.com/watch?v=XYZ_ZGSxU00'
+developmentStatus: stable
+inputTypes:
+  - application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
+  - text/csv
+it:
+  conforme:
+    gdpr: false
+    lineeGuidaDesign: false
+    misureMinimeSicurezza: false
+    modelloInteroperabilita: false
+  countryExtensionVersion: '0.2'
+  piattaforme:
+    anpr: false
+    cie: false
+    pagopa: false
+    spid: false
+landingURL: 'https://getgrist.com'
+legal:
+  license: Apache-2.0
+localisation:
+  availableLanguages:
+    - en
+    - fr
+    - ru
+    - de
+    - es
+    - pt
+    - zh
+    - it
+    - ja
+    - 'no'
+    - ro
+    - sl
+    - uk
+  localisationReady: true
+logo: |-
+  https://raw.githubusercontent.com/gristlabs/grist-core/master/static/img/logo-grist.png
+maintenance:
+  contacts:
+    - affiliation: Grist Labs
+      email: paul@getgrist.com
+      name: Paul Fitzpatrick
+  type: internal
+name: Grist
+outputTypes:
+  - application/x-sqlite3
+platforms:
+  - web
+releaseDate: '2024-06-12'
+roadmap: 'https://github.com/gristlabs/grist-core/projects/1'
+softwareType: standalone/other
+softwareVersion: 1.1.15
+url: 'https://github.com/gristlabs/grist-core'
+usedBy:
+  - 'ANCT (https://anct.gouv.fr)'
+  - 'DINUM (https://www.numerique.gouv.fr/dinum/)'

From 24ce54b586e20a260376a9e3d5b6774e3fa2b8b8 Mon Sep 17 00:00:00 2001
From: Leslie H <142967379+SleepyLeslie@users.noreply.github.com>
Date: Tue, 25 Jun 2024 15:43:25 -0400
Subject: [PATCH 003/145] Improve session ID security (#1059)

Follow-up of #994. This PR revises the session ID generation logic to improve security in the absence of a secure session secret. It also adds a section in the admin panel "security" section to nag system admins when GRIST_SESSION_SECRET is not set.

Following is an excerpt from internal conversation.

TL;DR: Grist's current implementation generates semi-secure session IDs and uses a publicly known default signing key to sign them when the environment variable GRIST_SESSION_SECRET is not set. This PR generates cryptographically secure session IDs to dismiss security concerns around an insecure signing key, and encourages system admins to configure their own signing key anyway.

> The session secret is required by expressjs/session to sign its session IDs. It's designed as an extra protection against session hijacking by randomly guessing session IDs and hitting a valid one. While it is easy to encourage users to set a distinct session secret, this is unnecessary if session IDs are generated in a cryptographically secure way. As of now Grist uses version 4 UUIDs as session IDs (see app/server/lib/gristSessions.ts - it uses shortUUID.generate which invokes uuid.v4 under the hood). These contain 122 bits of entropy, technically insufficient to be considered cryptographically secure. In practice, this is never considered a real vulnerability. To compare, RSA2048 is still very commonly used in web servers, yet it only has 112 bits of security (>=128 bits = "secure", rule of thumb in cryptography). But for peace of mind I propose using crypto.getRandomValues to generate real 128-bit random values. This should render session ID signing unnecessary and hence dismiss security concerns around an insecure signing key.
---
 app/client/ui/AdminPanel.ts     | 34 ++++++++++++++++++++++++++++++++-
 app/common/BootProbe.ts         |  3 ++-
 app/server/lib/BootProbes.ts    | 16 ++++++++++++++++
 app/server/lib/coreCreator.ts   |  5 ++++-
 app/server/lib/gristSessions.ts |  7 +++++--
 static/locales/en.client.json   |  1 +
 6 files changed, 61 insertions(+), 5 deletions(-)

diff --git a/app/client/ui/AdminPanel.ts b/app/client/ui/AdminPanel.ts
index bfd2bd6c..91f6802b 100644
--- a/app/client/ui/AdminPanel.ts
+++ b/app/client/ui/AdminPanel.ts
@@ -145,6 +145,13 @@ Please log in as an administrator.`)),
           description: t('Current authentication method'),
           value: this._buildAuthenticationDisplay(owner),
           expandedContent: this._buildAuthenticationNotice(owner),
+        }),
+        dom.create(AdminSectionItem, {
+          id: 'session',
+          name: t('Session Secret'),
+          description: t('Key to sign sessions with'),
+          value: this._buildSessionSecretDisplay(owner),
+          expandedContent: this._buildSessionSecretNotice(owner),
         })
       ]),
       dom.create(AdminSection, t('Version'), [
@@ -241,6 +248,27 @@ We recommend enabling one of these if Grist is accessible over the network or be
 to multiple people.');
   }
 
+  private _buildSessionSecretDisplay(owner: IDisposableOwner) {
+    return dom.domComputed(
+      use => {
+        const req = this._checks.requestCheckById(use, 'session-secret');
+        const result = req ? use(req.result) : undefined;
+
+        if (result?.status === 'warning') {
+          return cssValueLabel(cssDangerText('default'));
+        }
+
+        return cssValueLabel(cssHappyText('configured'));
+      }
+    );
+  }
+
+  private _buildSessionSecretNotice(owner: IDisposableOwner) {
+    return t('Grist signs user session cookies with a secret key. Please set this key via the environment variable \
+GRIST_SESSION_SECRET. Grist falls back to a hard-coded default when it is not set. We may remove this notice \
+in the future as session IDs generated since v1.1.16 are inherently cryptographically secure.');
+  }
+
   private _buildUpdates(owner: MultiHolder) {
     // We can be in those states:
     enum State {
@@ -472,7 +500,11 @@ to multiple people.');
     return dom.domComputed(
       use => [
         ...use(this._checks.probes).map(probe => {
-          const isRedundant = probe.id === 'sandboxing';
+          const isRedundant = [
+            'sandboxing',
+            'authentication',
+            'session-secret'
+          ].includes(probe.id);
           const show = isRedundant ? options.showRedundant : options.showNovel;
           if (!show) { return null; }
           const req = this._checks.requestCheck(probe);
diff --git a/app/common/BootProbe.ts b/app/common/BootProbe.ts
index 49fb9911..c73843b1 100644
--- a/app/common/BootProbe.ts
+++ b/app/common/BootProbe.ts
@@ -8,7 +8,8 @@ export type BootProbeIds =
     'sandboxing' |
     'system-user' |
     'authentication' |
-    'websockets'
+    'websockets' |
+    'session-secret'
 ;
 
 export interface BootProbeResult {
diff --git a/app/server/lib/BootProbes.ts b/app/server/lib/BootProbes.ts
index 61ac66eb..adef4811 100644
--- a/app/server/lib/BootProbes.ts
+++ b/app/server/lib/BootProbes.ts
@@ -6,6 +6,7 @@ import { GristServer } from 'app/server/lib/GristServer';
 import * as express from 'express';
 import WS from 'ws';
 import fetch from 'node-fetch';
+import { DEFAULT_SESSION_SECRET } from 'app/server/lib/coreCreator';
 
 /**
  * Self-diagnostics useful when installing Grist.
@@ -61,6 +62,7 @@ export class BootProbes {
     this._probes.push(_sandboxingProbe);
     this._probes.push(_authenticationProbe);
     this._probes.push(_webSocketsProbe);
+    this._probes.push(_sessionSecretProbe);
     this._probeById = new Map(this._probes.map(p => [p.id, p]));
   }
 }
@@ -284,3 +286,17 @@ const _authenticationProbe: Probe = {
     };
   },
 };
+
+const _sessionSecretProbe: Probe = {
+  id: 'session-secret',
+  name: 'Session secret',
+  apply: async(server, req) => {
+    const usingDefaultSessionSecret = server.create.sessionSecret() === DEFAULT_SESSION_SECRET;
+    return {
+      status: usingDefaultSessionSecret ? 'warning' : 'success',
+      details: {
+        "GRIST_SESSION_SECRET": process.env.GRIST_SESSION_SECRET ? "set" : "not set",
+      }
+    };
+  },
+};
diff --git a/app/server/lib/coreCreator.ts b/app/server/lib/coreCreator.ts
index f4536c16..477c970b 100644
--- a/app/server/lib/coreCreator.ts
+++ b/app/server/lib/coreCreator.ts
@@ -3,11 +3,14 @@ import { checkMinIOBucket, checkMinIOExternalStorage,
 import { makeSimpleCreator } from 'app/server/lib/ICreate';
 import { Telemetry } from 'app/server/lib/Telemetry';
 
+export const DEFAULT_SESSION_SECRET =
+  'Phoo2ag1jaiz6Moo2Iese2xoaphahbai3oNg7diemohlah0ohtae9iengafieS2Hae7quungoCi9iaPh';
+
 export const makeCoreCreator = () => makeSimpleCreator({
   deploymentType: 'core',
   // This can and should be overridden by GRIST_SESSION_SECRET
   // (or generated randomly per install, like grist-omnibus does).
-  sessionSecret: 'Phoo2ag1jaiz6Moo2Iese2xoaphahbai3oNg7diemohlah0ohtae9iengafieS2Hae7quungoCi9iaPh',
+  sessionSecret: DEFAULT_SESSION_SECRET,
   storage: [
     {
       name: 'minio',
diff --git a/app/server/lib/gristSessions.ts b/app/server/lib/gristSessions.ts
index 987fae58..a5555ba1 100644
--- a/app/server/lib/gristSessions.ts
+++ b/app/server/lib/gristSessions.ts
@@ -6,10 +6,10 @@ import {GristServer} from 'app/server/lib/GristServer';
 import {fromCallback} from 'app/server/lib/serverUtils';
 import {Sessions} from 'app/server/lib/Sessions';
 import {promisifyAll} from 'bluebird';
+import * as crypto from 'crypto';
 import * as express from 'express';
 import assignIn = require('lodash/assignIn');
 import * as path from 'path';
-import * as shortUUID from "short-uuid";
 
 
 export const cookieName = process.env.GRIST_SESSION_COOKIE || 'grist_sid';
@@ -118,7 +118,10 @@ export function initGristSessions(instanceRoot: string, server: GristServer) {
   // cookie could be stolen (with some effort) by the custom domain's owner, we limit the damage
   // by only honoring custom-domain cookies for requests to that domain.
   const generateId = (req: RequestWithOrg) => {
-    const uid = shortUUID.generate();
+    // Generate 256 bits of cryptographically random data to use as the session ID.
+    // This ensures security against brute-force session hijacking even without signing the session ID.
+    const randomNumbers = crypto.getRandomValues(new Uint8Array(32));
+    const uid = Buffer.from(randomNumbers).toString("hex");
     return req.isCustomHost ? `c-${uid}@${req.org}@${req.get('host')}` : `g-${uid}`;
   };
   const sessionSecret = server.create.sessionSecret();
diff --git a/static/locales/en.client.json b/static/locales/en.client.json
index 7a8ee8df..baae4538 100644
--- a/static/locales/en.client.json
+++ b/static/locales/en.client.json
@@ -1541,6 +1541,7 @@
         "Error": "Error",
         "Error checking for updates": "Error checking for updates",
         "Grist allows for very powerful formulas, using Python. We recommend setting the environment variable GRIST_SANDBOX_FLAVOR to gvisor if your hardware supports it (most will), to run formulas in each document within a sandbox isolated from other documents and isolated from the network.": "Grist allows for very powerful formulas, using Python. We recommend setting the environment variable GRIST_SANDBOX_FLAVOR to gvisor if your hardware supports it (most will), to run formulas in each document within a sandbox isolated from other documents and isolated from the network.",
+        "Grist signs user session cookies with a secret key. Please set this key via the environment variable GRIST_SESSION_SECRET. Grist falls back to a hard-coded default when it is not set. We may remove this notice in the future since session IDs have been updated to be inherently cryptographically secure.": "Grist signs user session cookies with a secret key. Please set this key via the environment variable GRIST_SESSION_SECRET. Grist falls back to a hard-coded default when it is not set. We may remove this notice in the future as session IDs generated since v1.1.16 are inherently cryptographically secure.",
         "Grist is up to date": "Grist is up to date",
         "Grist releases are at ": "Grist releases are at ",
         "Last checked {{time}}": "Last checked {{time}}",

From c0e4cea273bffd7b9b20dd273b0efbb5eb13fe8d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Fran=C4=8Dek=20Prijatelj?= <prijatelj.francek@gmail.com>
Date: Tue, 25 Jun 2024 10:12:02 +0000
Subject: [PATCH 004/145] Translated using Weblate (Slovenian)

Currently translated at 100.0% (1336 of 1336 strings)

Translation: Grist/client
Translate-URL: https://hosted.weblate.org/projects/grist/client/sl/
---
 static/locales/sl.client.json | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)

diff --git a/static/locales/sl.client.json b/static/locales/sl.client.json
index f359c24b..7eeb44eb 100644
--- a/static/locales/sl.client.json
+++ b/static/locales/sl.client.json
@@ -538,7 +538,9 @@
         "Cancel": "Prekliči",
         "Force reload the document while timing formulas, and show the result.": "Prisilno znova naloži dokument med časovnimi formulami in prikaži rezultat.",
         "Timing is on": "Merjenje časa je vklopljeno",
-        "You can make changes to the document, then stop timing to see the results.": "Dokument lahko spremeniš in nato ustaviš merjenje časa, da vidiš rezultat."
+        "You can make changes to the document, then stop timing to see the results.": "Dokument lahko spremeniš in nato ustaviš merjenje časa, da vidiš rezultat.",
+        "Only available to document editors": "Na voljo samo urednikom dokumentov",
+        "Only available to document owners": "Na voljo samo lastnikom dokumentov"
     },
     "GridOptions": {
         "Horizontal Gridlines": "Vodoravne linije",

From eed5f364c07dd5793fa389909ca86d6b857f3d8c Mon Sep 17 00:00:00 2001
From: Roman Holinec <3ko@pixeon.sk>
Date: Tue, 25 Jun 2024 18:28:19 +0000
Subject: [PATCH 005/145] Translated using Weblate (Slovak)

Currently translated at 37.1% (496 of 1336 strings)

Translation: Grist/client
Translate-URL: https://hosted.weblate.org/projects/grist/client/sk/
---
 static/locales/sk.client.json | 112 +++++++++++++++++++++++++++++++++-
 1 file changed, 111 insertions(+), 1 deletion(-)

diff --git a/static/locales/sk.client.json b/static/locales/sk.client.json
index abc89726..b5b72d72 100644
--- a/static/locales/sk.client.json
+++ b/static/locales/sk.client.json
@@ -452,7 +452,35 @@
         "Insert column to the {{to}}": "Vložiť stĺpec do {{to}}",
         "More sort options ...": "Ďalšie možnosti zoradenia…",
         "Rename column": "Premenovať stĺpec",
-        "Created At": "Vytvorené v"
+        "Created At": "Vytvorené v",
+        "Detect Duplicates in...": "Zistiť duplikáty v...",
+        "Duplicate in {{- label}}": "Duplikovať v {{- label}}",
+        "Shortcuts": "Skratky",
+        "Adding UUID column": "Pridávať stĺpec UUID",
+        "Adding duplicates column": "Pridávať stĺpec duplikátov",
+        "Search columns": "Prehľadať stĺpce",
+        "UUID": "UUID",
+        "No reference columns.": "Žiadne referenčné stĺpce.",
+        "Add formula column": "Pridať stĺpec vzorca",
+        "Add column with type": "Pridať stĺpec s typom",
+        "Add column": "Pridať stĺpec",
+        "Created at": "Vytvorené v",
+        "Created by": "Vytvoril",
+        "Detect duplicates in...": "Zistiť duplikáty v...",
+        "DateTime": "Dátum a Čas",
+        "Choice": "Voľba",
+        "Reference": "Referencia",
+        "Last updated at": "Naposledy aktualizované v",
+        "Last updated by": "Naposledy aktualizoval",
+        "Date": "Dátum",
+        "Any": "Akýkoľvek",
+        "Text": "Text",
+        "Integer": "Celé číslo",
+        "Numeric": "Desatinné číslo",
+        "Toggle": "Prepínať",
+        "Choice List": "Výberový zoznam",
+        "Reference List": "Zoznam referencií",
+        "Attachment": "Príloha"
     },
     "GridOptions": {
         "Horizontal Gridlines": "Horizontálna línia Mriežky",
@@ -462,5 +490,87 @@
     },
     "FilterConfig": {
         "Add Column": "Pridať Stĺpec"
+    },
+    "HomeIntro": {
+        "Welcome to {{- orgName}}": "Vitajte v {{- orgName}}",
+        "{{signUp}} to save your work. ": "Ak chcete uložiť svoju prácu, {{signUp}}. ",
+        "Welcome to Grist, {{- name}}!": "Vitajte v Grist, {{- name}}!",
+        "Any documents created in this site will appear here.": "Všetky dokumenty vytvorené na tejto stránke sa zobrazia tu.",
+        "Browse Templates": "Prechádzať Šablóny",
+        "Create Empty Document": "Vytvoriť Prázdny Dokument",
+        "Get started by creating your first Grist document.": "Začať vytvorením svojho prvého dokumentu Grist.",
+        "Get started by exploring templates, or creating your first Grist document.": "Začať skúmaním šablón, alebo vytvorením prvého dokumentu Grist.",
+        "Get started by inviting your team and creating your first Grist document.": "Začať tým, že pozvete svoj tím a vytvoríte svoj prvý dokument Grist.",
+        "Help Center": "Centrum Pomoci",
+        "Import Document": "Importovať Dokument",
+        "Invite Team Members": "Pozvať Členov Tímu",
+        "This workspace is empty.": "Tento pracovný priestor je prázdny.",
+        "Sprouts Program": "Program Klíčenia",
+        "Visit our {{link}} to learn more.": "Viac informácií na našej stránke {{link}}.",
+        "Welcome to Grist!": "Vitajte v Grist!",
+        "Welcome to Grist, {{name}}!": "Vitajte v Grist, {{name}}!",
+        "Welcome to {{orgName}}": "Vitajte v {{orgName}}",
+        "You have read-only access to this site. Currently there are no documents.": "K tejto lokalite máte prístup iba na čítanie. V súčasnosti neexistujú žiadne dokumenty.",
+        "personal site": "osobná stránka",
+        "Sign in": "Prihlásiť sa",
+        "To use Grist, please either sign up or sign in.": "Ak chcete používať Grist, zaregistrujte sa alebo sa prihláste.",
+        "Visit our {{link}} to learn more about Grist.": "Navštíviť náš {{link}} a dozvedieť sa viac o Grist.",
+        "Learn more in our {{helpCenterLink}}, or find an expert via our {{sproutsProgram}}.": "Viac informácií nájdete v našom {{helpCenterLink}} alebo nájdite odborníka prostredníctvom nášho {{sproutsProgram}}.",
+        "Interested in using Grist outside of your team? Visit your free ": "Máte záujem používať Grist mimo váš tím?  Navštívte svoje bezplatné ",
+        "Sign up": "Prihlásiť sa"
+    },
+    "Importer": {
+        "Merge rows that match these fields:": "Zlúčiť riadky, ktoré zodpovedajú týmto poliam:",
+        "Select fields to match on": "Vyberte polia podľa obsahu",
+        "Update existing records": "Aktualizovať existujúce záznamy",
+        "{{count}} unmatched field in import_one": "{{count}} nezhodné pole v importe",
+        "{{count}} unmatched field in import_other": "{{count}} nezhodných polí pri importe",
+        "{{count}} unmatched field_one": "{{count}} nezhodné pole",
+        "{{count}} unmatched field_other": "{{count}} nezhodné polia",
+        "Column Mapping": "Mapovanie Stĺpcov",
+        "Column mapping": "Mapovanie stĺpcov",
+        "Destination table": "Cieľová tabuľka",
+        "Grist column": "Grist stĺpec",
+        "Import from file": "Importovať zo súboru",
+        "New Table": "Nová Tabuľka",
+        "Revert": "Návrat",
+        "Skip": "Preskočiť",
+        "Skip Import": "Preskočiť import",
+        "Skip Table on Import": "Preskočiť Tabuľku pri Importe",
+        "Source column": "Zdrojový stĺpec"
+    },
+    "MakeCopyMenu": {
+        "As Template": "Ako Šablóna",
+        "Enter document name": "Zadajte názov dokumentu",
+        "Be careful, the original has changes not in this document. Those changes will be overwritten.": "Buďte opatrní, originál obsahuje zmeny, ktoré nie sú v tomto dokumente. Tieto zmeny budú prepísané.",
+        "However, it appears to be already identical.": "Zdá sa však, že je už identický.",
+        "Include the structure without any of the data.": "Zahrňte štruktúru bez akýchkoľvek údajov.",
+        "Cancel": "Zrušiť"
+    },
+    "GristDoc": {
+        "Added new linked section to view {{viewName}}": "Pridaná nová prepojená sekcia na zobrazenie {{viewName}}",
+        "Import from file": "Importovať zo súboru",
+        "Saved linked section {{title}} in view {{name}}": "Uložená prepojená sekcia {{title}} v zobrazení {{name}}",
+        "go to webhook settings": "prejsť na nastavenia webhooku"
+    },
+    "HomeLeftPane": {
+        "Access Details": "Podrobnosti Prístupu",
+        "All Documents": "Všetky Dokumenty",
+        "Create Empty Document": "Vytvoriť Prázdny Dokument",
+        "Create Workspace": "Vytvoriť Pracovný priestor",
+        "Delete": "Odstrániť",
+        "Delete {{workspace}} and all included documents?": "Odstrániť {{workspace}} a všetky zahrnuté dokumenty?",
+        "Examples & Templates": "Šablóny",
+        "Import Document": "Importovať Dokument",
+        "Manage Users": "Spravovať Používateľov",
+        "Rename": "Premenovať",
+        "Trash": "Odpad",
+        "Workspace will be moved to Trash.": "Pracovný priestor sa presunie do Koša.",
+        "Workspaces": "Pracovné priestory",
+        "Terms of service": "Podmienky služby",
+        "Tutorial": "Návod"
+    },
+    "LeftPanelCommon": {
+        "Help Center": "Centrum Pomoci"
     }
 }

From 187358cfa26565a45dcc9d7207b978ea16b1fac6 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
 <41898282+github-actions[bot]@users.noreply.github.com>
Date: Wed, 26 Jun 2024 08:36:15 -0400
Subject: [PATCH 006/145] automated update to translation keys (#1065)

Co-authored-by: Paul's Grist Bot <paul+bot@getgrist.com>
---
 static/locales/en.client.json | 5 ++++-
 1 file changed, 4 insertions(+), 1 deletion(-)

diff --git a/static/locales/en.client.json b/static/locales/en.client.json
index baae4538..420e580e 100644
--- a/static/locales/en.client.json
+++ b/static/locales/en.client.json
@@ -1568,7 +1568,10 @@
         "Results": "Results",
         "Self Checks": "Self Checks",
         "You do not have access to the administrator panel.\nPlease log in as an administrator.": "You do not have access to the administrator panel.\nPlease log in as an administrator.",
-        "Grist allows different types of authentication to be configured, including SAML and OIDC. We recommend enabling one of these if Grist is accessible over the network or being made available to multiple people.": "Grist allows different types of authentication to be configured, including SAML and OIDC. We recommend enabling one of these if Grist is accessible over the network or being made available to multiple people."
+        "Grist allows different types of authentication to be configured, including SAML and OIDC. We recommend enabling one of these if Grist is accessible over the network or being made available to multiple people.": "Grist allows different types of authentication to be configured, including SAML and OIDC. We recommend enabling one of these if Grist is accessible over the network or being made available to multiple people.",
+        "Grist signs user session cookies with a secret key. Please set this key via the environment variable GRIST_SESSION_SECRET. Grist falls back to a hard-coded default when it is not set. We may remove this notice in the future as session IDs generated since v1.1.16 are inherently cryptographically secure.": "Grist signs user session cookies with a secret key. Please set this key via the environment variable GRIST_SESSION_SECRET. Grist falls back to a hard-coded default when it is not set. We may remove this notice in the future as session IDs generated since v1.1.16 are inherently cryptographically secure.",
+        "Key to sign sessions with": "Key to sign sessions with",
+        "Session Secret": "Session Secret"
     },
     "Columns": {
         "Remove Column": "Remove Column"

From 3b3aa8a86e922b2f5409da86ca0753f3a048837c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= <jordigh@octave.org>
Date: Wed, 19 Jun 2024 12:17:48 -0400
Subject: [PATCH 007/145] build.sh: add some diagnostic output

As I was testing, I found it useful to see when I was using the ext/
directory or not.
---
 buildtools/build.sh | 4 ++++
 1 file changed, 4 insertions(+)

diff --git a/buildtools/build.sh b/buildtools/build.sh
index c87643a6..4c66a345 100755
--- a/buildtools/build.sh
+++ b/buildtools/build.sh
@@ -5,7 +5,11 @@ set -e
 PROJECT=""
 if [[ -e ext/app ]]; then
   PROJECT="tsconfig-ext.json"
+  echo "Using extra app directory"
+else
+  echo "No extra app directory found"
 fi
+
 WEBPACK_CONFIG=buildtools/webpack.config.js
 if [[ -e ext/buildtools/webpack.config.js ]]; then
   # Allow webpack config file to be replaced (useful

From 36f897fd359dab95bbb053557ecb239b1d7c7cce Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= <jordigh@octave.org>
Date: Thu, 20 Jun 2024 16:36:03 -0400
Subject: [PATCH 008/145] .grist-ee-version: start referencing the intended
 enterprise version

Since we won't be tracking ext/-directory providers via git (e.g. no
submodules), instead we'll do little version-tracking files like this,
to be used by the recent ext-checkout script.
---
 buildtools/.grist-ee-version | 1 +
 1 file changed, 1 insertion(+)
 create mode 100644 buildtools/.grist-ee-version

diff --git a/buildtools/.grist-ee-version b/buildtools/.grist-ee-version
new file mode 100644
index 00000000..2003b639
--- /dev/null
+++ b/buildtools/.grist-ee-version
@@ -0,0 +1 @@
+0.9.2

From bd7b7b778b6e6abc7df634a43f0c3cb11618989d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= <jordigh@octave.org>
Date: Wed, 19 Jun 2024 12:18:34 -0400
Subject: [PATCH 009/145] checkout-ext-directory: new helper script

This is just a helper script to get the ext directory of other grist
repos, currently intended for grist-ee.
---
 buildtools/checkout-ext-directory.sh | 18 ++++++++++++++++++
 1 file changed, 18 insertions(+)
 create mode 100755 buildtools/checkout-ext-directory.sh

diff --git a/buildtools/checkout-ext-directory.sh b/buildtools/checkout-ext-directory.sh
new file mode 100755
index 00000000..6861b9e2
--- /dev/null
+++ b/buildtools/checkout-ext-directory.sh
@@ -0,0 +1,18 @@
+#!/usr/bin/env bash
+
+# This checks out the ext/ directory from the extra repo (e.g.
+# grist-ee or grist-desktop) depending on the supplied repo name.
+
+set -e
+
+repo=$1
+dir=$(dirname $0)
+ref=$(cat $dir/.$repo-version)
+
+git clone --branch $ref --depth 1 --filter=tree:0 "https://github.com/gristlabs/$repo"
+pushd $repo
+git sparse-checkout set ext
+git checkout
+popd
+mv $repo/ext .
+rm -rf $repo

From bc52f65b2648ff7987383537fc06c6a388d29ce2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= <jordigh@octave.org>
Date: Wed, 19 Jun 2024 14:27:43 -0400
Subject: [PATCH 010/145] tsconfig-ext: add /app, /test, and /stubs/app
 directories

This is so that they get built and tested, as we'll start running
tests on the ext/ directories from now on.
---
 tsconfig-ext.json | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/tsconfig-ext.json b/tsconfig-ext.json
index 814d5c36..cfa355ab 100644
--- a/tsconfig-ext.json
+++ b/tsconfig-ext.json
@@ -3,6 +3,9 @@
   "files": [],
   "include": [],
   "references": [
+    { "path": "./app" },
+    { "path": "./stubs/app" },
+    { "path": "./test" },
     { "path": "./ext/app" }
   ],
 }

From 70ed8553b3a58b5607789627f5386037d0101362 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= <jordigh@octave.org>
Date: Fri, 21 Jun 2024 17:55:30 -0400
Subject: [PATCH 011/145] workflows: build the grist-oss, grist, and grist-ee
 images

This modifies the workflow to build grist-ee images as well as grist,
which is the same image as grist-ee but merely renamed. The original
image built by these workflows is now called grist-oss.
---
 .github/workflows/docker.yml        | 27 ++++++++++++++---
 .github/workflows/docker_latest.yml | 46 +++++++++++++++++++++++++----
 2 files changed, 64 insertions(+), 9 deletions(-)

diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
index ef1f5d7b..3ffafb53 100644
--- a/.github/workflows/docker.yml
+++ b/.github/workflows/docker.yml
@@ -8,17 +8,31 @@ on:
 
 jobs:
   push_to_registry:
-    name: Push Docker image to Docker Hub
+    name: Push Docker images to Docker Hub
     runs-on: ubuntu-latest
+    strategy:
+      matrix:
+        image:
+          - name: "grist-oss"
+            repo: "grist-core"
+          - name: "grist"
+            repo: "grist-ee"
+          - name: "grist-ee"
+            repo: "grist-ee"
     steps:
       - name: Check out the repo
-        uses: actions/checkout@v2
+        uses: actions/checkout@v3
+
+      - name: Check out the ext/ directory
+        if: matrix.image.name != 'grist-oss'
+        run: buildtools/checkout-ext-directory.sh ${{ matrix.image.repo }}
+
       - name: Docker meta
         id: meta
         uses: docker/metadata-action@v4
         with:
           images: |
-            ${{ github.repository_owner }}/grist
+            ${{ github.repository_owner }}/${{ matrix.image.name }}
           tags: |
             type=ref,event=branch
             type=ref,event=pr
@@ -28,13 +42,16 @@ jobs:
             stable
       - name: Set up QEMU
         uses: docker/setup-qemu-action@v1
+
       - name: Set up Docker Buildx
         uses: docker/setup-buildx-action@v1
+
       - name: Log in to Docker Hub
-        uses: docker/login-action@v1
+        uses: docker/login-action@v3
         with:
           username: ${{ secrets.DOCKER_USERNAME }}
           password: ${{ secrets.DOCKER_PASSWORD }}
+
       - name: Push to Docker Hub
         uses: docker/build-push-action@v2
         with:
@@ -44,3 +61,5 @@ jobs:
           tags: ${{ steps.meta.outputs.tags }}
           cache-from: type=gha
           cache-to: type=gha,mode=max
+          build-contexts: ${{ matrix.image.name != 'grist-oss' && 'ext=ext' || '' }}
+
diff --git a/.github/workflows/docker_latest.yml b/.github/workflows/docker_latest.yml
index 5b79ef03..e6cea6f8 100644
--- a/.github/workflows/docker_latest.yml
+++ b/.github/workflows/docker_latest.yml
@@ -10,6 +10,12 @@ on:
     # Run at 5:41 UTC daily
     - cron:  '41 5 * * *'
   workflow_dispatch:
+    inputs:
+      latest_branch:
+        description: Branch from which to create the latest Docker image (default: latest_candidate)
+        type: string
+        required: true
+        default_value: latest_candidate
 
 jobs:
   push_to_registry:
@@ -19,54 +25,84 @@ jobs:
       matrix:
         python-version: [3.9]
         node-version: [18.x]
+        image:
+          - name: "grist-oss"
+            repo: "grist-core"
+          - name: "grist"
+            repo: "grist-ee"
+          - name: "grist-ee"
+            repo: "grist-ee"
     steps:
       - name: Check out the repo
         uses: actions/checkout@v2
         with:
-          ref: latest_candidate
+          ref: ${{ inputs.latest_branch }}
+
+      - name: Check out the ext/ directory
+        if: matrix.image.name != 'grist-oss'
+        run: buildtools/checkout-ext-directory.sh ${{ matrix.image.repo }}
+
+
       - name: Set up QEMU
         uses: docker/setup-qemu-action@v1
+
       - name: Set up Docker Buildx
         uses: docker/setup-buildx-action@v1
+
       - name: Prepare image but do not push it yet
         uses: docker/build-push-action@v2
         with:
           context: .
           load: true
-          tags: ${{ github.repository_owner }}/grist:latest
+          tags: ${{ github.repository_owner }}/${{ matrix.image.name }}:experimental
           cache-from: type=gha
+          build-contexts: ${{ matrix.image.name != 'grist-oss' && 'ext=ext' || '' }}
+
       - name: Use Node.js ${{ matrix.node-version }} for testing
         uses: actions/setup-node@v1
         with:
           node-version: ${{ matrix.node-version }}
+
       - name: Set up Python ${{ matrix.python-version }} for testing - maybe not needed
         uses: actions/setup-python@v2
         with:
           python-version: ${{ matrix.python-version }}
+
       - name: Install Python packages
         run: |
           pip install virtualenv
           yarn run install:python
+
       - name: Install Node.js packages
         run: yarn install
+
       - name: Build Node.js code
-        run: yarn run build:prod
+        run: |
+          pushd ext && \
+          { if [ -e package.json ] ; then yarn install --frozen-lockfile --modules-folder=../../node_modules; fi } && \
+          popd
+          yarn run build:prod
+
       - name: Run tests
-        run: TEST_IMAGE=${{ github.repository_owner }}/grist VERBOSE=1 DEBUG=1 MOCHA_WEBDRIVER_HEADLESS=1 yarn run test:docker
+        run: TEST_IMAGE=${{ github.repository_owner }}/${{ matrix.image.name }} VERBOSE=1 DEBUG=1 MOCHA_WEBDRIVER_HEADLESS=1 yarn run test:docker
+
       - name: Log in to Docker Hub
         uses: docker/login-action@v1 
         with:
           username: ${{ secrets.DOCKER_USERNAME }}
           password: ${{ secrets.DOCKER_PASSWORD }}
+
       - name: Push to Docker Hub
         uses: docker/build-push-action@v2
         with:
           context: .
           platforms: linux/amd64,linux/arm64/v8
           push: true
-          tags: ${{ github.repository_owner }}/grist:latest
+          tags: ${{ github.repository_owner }}/${{ matrix.image.name }}:experimental
           cache-from: type=gha
           cache-to: type=gha,mode=max
+          build-contexts: ${{ matrix.image.name != 'grist-oss' && 'ext=ext' || '' }}
+
       - name: Update latest branch
         uses: ad-m/github-push-action@8407731efefc0d8f72af254c74276b7a90be36e1
         with:

From 8cd9e40744d7ed6801daab5991039cf8a0472850 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= <jordigh@octave.org>
Date: Fri, 21 Jun 2024 17:56:07 -0400
Subject: [PATCH 012/145] README: Mention the two possible docker images

---
 .github/workflows/docker.yml        |  4 ++++
 .github/workflows/docker_latest.yml |  4 ++++
 README.md                           | 19 ++++++++++++++++++-
 3 files changed, 26 insertions(+), 1 deletion(-)

diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
index 3ffafb53..f80c204c 100644
--- a/.github/workflows/docker.yml
+++ b/.github/workflows/docker.yml
@@ -13,10 +13,14 @@ jobs:
     strategy:
       matrix:
         image:
+          # We build two images, `grist-oss` and `grist`.
+          # See https://github.com/gristlabs/grist-core?tab=readme-ov-file#available-docker-images
           - name: "grist-oss"
             repo: "grist-core"
           - name: "grist"
             repo: "grist-ee"
+          # For now, we build it twice, with `grist-ee` being a
+          # backwards-compatible synoym for `grist`.
           - name: "grist-ee"
             repo: "grist-ee"
     steps:
diff --git a/.github/workflows/docker_latest.yml b/.github/workflows/docker_latest.yml
index e6cea6f8..e105f01e 100644
--- a/.github/workflows/docker_latest.yml
+++ b/.github/workflows/docker_latest.yml
@@ -26,10 +26,14 @@ jobs:
         python-version: [3.9]
         node-version: [18.x]
         image:
+          # We build two images, `grist-oss` and `grist`.
+          # See https://github.com/gristlabs/grist-core?tab=readme-ov-file#available-docker-images
           - name: "grist-oss"
             repo: "grist-core"
           - name: "grist"
             repo: "grist-ee"
+          # For now, we build it twice, with `grist-ee` being a
+          # backwards-compatible synoym for `grist`.
           - name: "grist-ee"
             repo: "grist-ee"
     steps:
diff --git a/README.md b/README.md
index ee9099d9..1f4f1b4e 100644
--- a/README.md
+++ b/README.md
@@ -83,7 +83,8 @@ If you just want a quick demo of Grist:
   * Or you can see a fully in-browser build of Grist at [gristlabs.github.io/grist-static](https://gristlabs.github.io/grist-static/).
   * Or you can download Grist as a desktop app from [github.com/gristlabs/grist-desktop](https://github.com/gristlabs/grist-desktop).
 
-To get `grist-core` running on your computer with [Docker](https://www.docker.com/get-started), do:
+To get the default version of `grist-core` running on your computer
+with [Docker](https://www.docker.com/get-started), do:
 
 ```sh
 docker pull gristlabs/grist
@@ -117,6 +118,22 @@ You can find a lot more about configuring Grist, setting up authentication,
 and running it on a public server in our
 [Self-Managed Grist](https://support.getgrist.com/self-managed/) handbook.
 
+## Available Docker images
+
+The default Docker image is `gristlabs/grist`. This contains all of
+the standard Grist functionality, as well as extra source-available
+code for enterprise customers taken from the the
+[grist-ee](https://github.com/gristlabs/grist-ee) repository. This
+extra code is not under a free or open source license. By default,
+however, the code from the `grist-ee` repository is completely inert and
+inactive. This code becomes active only when an administrator enables
+it by setting either `GRIST_ACTIVATION` or `GRIST_ACTIVATION_FILE`.
+
+If you would rather use an image that contains exclusively free and
+open source code, the `gristlabs/grist-oss` Docker image is available
+for this purpose. It is by default functionally equivalent to the
+`gristlabs/grist` image.
+
 ## The administrator panel
 
 You can turn on a special admininistrator panel to inspect the status

From 40f7060ac5c4e8691d3d5da4d78c8ffd56925371 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= <jordigh@octave.org>
Date: Wed, 26 Jun 2024 15:24:47 -0400
Subject: [PATCH 013/145] workflows: fix syntax error

Oops, have to quote the string because of the colon
---
 .github/workflows/docker_latest.yml | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/.github/workflows/docker_latest.yml b/.github/workflows/docker_latest.yml
index e105f01e..e5b7a5b8 100644
--- a/.github/workflows/docker_latest.yml
+++ b/.github/workflows/docker_latest.yml
@@ -12,7 +12,7 @@ on:
   workflow_dispatch:
     inputs:
       latest_branch:
-        description: Branch from which to create the latest Docker image (default: latest_candidate)
+        description: "Branch from which to create the latest Docker image (default: latest_candidate)"
         type: string
         required: true
         default_value: latest_candidate

From 184be9387fd2153299df0f30e67a1d94338b99dc Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jaros=C5=82aw=20Sadzi=C5=84ski?=
 <jaroslaw.sadzinski@gmail.com>
Date: Wed, 26 Jun 2024 15:49:13 +0200
Subject: [PATCH 014/145] (core) Enabling telemetry on /api/version endpoint

Summary:
Version API endpoint wasn't logging telemetry from POST requests. The issue was in registration
order, this endpoint was registered before `expressJson` and it couldn't read json body in the handler.

Test Plan: Added new test

Reviewers: paulfitz

Reviewed By: paulfitz

Subscribers: paulfitz

Differential Revision: https://phab.getgrist.com/D4277
---
 app/server/lib/FlexServer.ts        |  2 +-
 app/server/lib/UpdateManager.ts     |  5 +---
 app/server/mergedServerMain.ts      |  2 +-
 test/client/lib/UrlState.ts         |  5 ----
 test/client/models/gristUrlState.ts |  2 --
 test/gen-server/UpdateChecks.ts     | 40 ++++++++++++++++++++++++++---
 test/server/Comm.ts                 | 10 --------
 test/server/lib/DocApi.ts           |  2 +-
 8 files changed, 41 insertions(+), 27 deletions(-)

diff --git a/app/server/lib/FlexServer.ts b/app/server/lib/FlexServer.ts
index 46e4a508..015b3c11 100644
--- a/app/server/lib/FlexServer.ts
+++ b/app/server/lib/FlexServer.ts
@@ -1971,7 +1971,7 @@ export class FlexServer implements GristServer {
   }
 
   public addUpdatesCheck() {
-    if (this._check('update')) { return; }
+    if (this._check('update', 'json')) { return; }
 
     // For now we only are active for sass deployments.
     if (this._deploymentType !== 'saas') { return; }
diff --git a/app/server/lib/UpdateManager.ts b/app/server/lib/UpdateManager.ts
index c7ac9f67..a79c5b2c 100644
--- a/app/server/lib/UpdateManager.ts
+++ b/app/server/lib/UpdateManager.ts
@@ -85,15 +85,12 @@ export class UpdateManager {
       const payload = (name: string) => req.body?.[name] ?? req.query[name];
 
       // This is the most interesting part for us, to track installation ids and match them
-      // with the version of the client. Won't be send without telemetry opt in.
+      // with the version of the client.
       const deploymentId = optStringParam(
         payload("installationId"),
         "installationId"
       );
 
-      // Current version of grist-core part of the client. Currently not used and not
-      // passed from the client.
-
       // Deployment type of the client (we expect this to be 'core' for most of the cases).
       const deploymentType = optStringParam(
         payload("deploymentType"),
diff --git a/app/server/mergedServerMain.ts b/app/server/mergedServerMain.ts
index 8405cd8d..987343f6 100644
--- a/app/server/mergedServerMain.ts
+++ b/app/server/mergedServerMain.ts
@@ -106,7 +106,6 @@ export async function main(port: number, serverTypes: ServerType[],
   server.addHealthCheck();
   if (includeHome || includeApp) {
     server.addBootPage();
-    server.addUpdatesCheck();
   }
   server.denyRequestsIfNotReady();
 
@@ -148,6 +147,7 @@ export async function main(port: number, serverTypes: ServerType[],
         server.addDocApiForwarder();
       }
       server.addJsonSupport();
+      server.addUpdatesCheck();
       await server.addLandingPages();
       // todo: add support for home api to standalone app
       server.addHomeApi();
diff --git a/test/client/lib/UrlState.ts b/test/client/lib/UrlState.ts
index 5de50c2e..356c3c0e 100644
--- a/test/client/lib/UrlState.ts
+++ b/test/client/lib/UrlState.ts
@@ -1,14 +1,11 @@
-import * as log from 'app/client/lib/log';
 import {HistWindow, UrlState} from 'app/client/lib/UrlState';
 import {assert} from 'chai';
 import {dom} from 'grainjs';
 import {popGlobals, pushGlobals} from 'grainjs/dist/cjs/lib/browserGlobals';
 import {JSDOM} from 'jsdom';
 import fromPairs = require('lodash/fromPairs');
-import * as sinon from 'sinon';
 
 describe('UrlState', function() {
-  const sandbox = sinon.createSandbox();
   let mockWindow: HistWindow;
 
   function pushState(state: any, title: any, href: string) {
@@ -26,12 +23,10 @@ describe('UrlState', function() {
     // These grainjs browserGlobals are needed for using dom() in tests.
     const jsdomDoc = new JSDOM("<!doctype html><html><body></body></html>");
     pushGlobals(jsdomDoc.window);
-    sandbox.stub(log, 'debug');
   });
 
   afterEach(function() {
     popGlobals();
-    sandbox.restore();
   });
 
   interface State {
diff --git a/test/client/models/gristUrlState.ts b/test/client/models/gristUrlState.ts
index c7065fef..764b00fd 100644
--- a/test/client/models/gristUrlState.ts
+++ b/test/client/models/gristUrlState.ts
@@ -1,4 +1,3 @@
-import * as log from 'app/client/lib/log';
 import {HistWindow, UrlState} from 'app/client/lib/UrlState';
 import {getLoginUrl, UrlStateImpl} from 'app/client/models/gristUrlState';
 import {IGristUrlState} from 'app/common/gristUrls';
@@ -42,7 +41,6 @@ describe('gristUrlState', function() {
     // These grainjs browserGlobals are needed for using dom() in tests.
     const jsdomDoc = new JSDOM("<!doctype html><html><body></body></html>");
     pushGlobals(jsdomDoc.window);
-    sandbox.stub(log, 'debug');
   });
 
   afterEach(function() {
diff --git a/test/gen-server/UpdateChecks.ts b/test/gen-server/UpdateChecks.ts
index cdd9426f..53441f8c 100644
--- a/test/gen-server/UpdateChecks.ts
+++ b/test/gen-server/UpdateChecks.ts
@@ -5,10 +5,12 @@ import * as sinon from 'sinon';
 import { configForUser } from "test/gen-server/testUtils";
 import * as testUtils from "test/server/testUtils";
 import { Defer, serveSomething, Serving } from "test/server/customUtil";
+import { Telemetry } from 'app/server/lib/Telemetry';
 import { Deps } from "app/server/lib/UpdateManager";
 import { TestServer } from "test/gen-server/apiUtils";
 import { delay } from "app/common/delay";
 import { LatestVersion } from 'app/common/InstallAPI';
+import { TelemetryEvent, TelemetryMetadataByLevel } from 'app/common/Telemetry';
 
 const assert = chai.assert;
 
@@ -21,8 +23,13 @@ const stop = async () => {
 
 let homeUrl: string;
 let dockerHub: Serving & { signal: () => Defer };
+let sandbox: sinon.SinonSandbox;
+const logMessages: [TelemetryEvent, TelemetryMetadataByLevel?][] = [];
 
 const chimpy = configForUser("Chimpy");
+const headers = {
+  headers: {'Content-Type': 'application/json'}
+};
 
 // Tests specific complex scenarios that may have previously resulted in wrong behavior.
 describe("UpdateChecks", function () {
@@ -30,8 +37,6 @@ describe("UpdateChecks", function () {
 
   this.timeout("20s");
 
-  const sandbox = sinon.createSandbox();
-
   before(async function () {
     testUtils.EnvironmentSnapshot.push();
     dockerHub = await dummyDockerHub();
@@ -41,11 +46,19 @@ describe("UpdateChecks", function () {
     Object.assign(process.env, {
       GRIST_TEST_SERVER_DEPLOYMENT_TYPE: "saas",
     });
+    sandbox = sinon.createSandbox();
     sandbox.stub(Deps, "REQUEST_TIMEOUT").value(300);
     sandbox.stub(Deps, "RETRY_TIMEOUT").value(400);
     sandbox.stub(Deps, "GOOD_RESULT_TTL").value(500);
     sandbox.stub(Deps, "BAD_RESULT_TTL").value(200);
     sandbox.stub(Deps, "DOCKER_ENDPOINT").value(dockerHub.url + "/tags");
+    sandbox.stub(Telemetry.prototype, 'logEvent').callsFake((_, name, meta) => {
+      if (name !== 'checkedUpdateAPI') {
+        return Promise.resolve();
+      }
+      logMessages.push([name, meta]);
+      return Promise.resolve();
+    });
 
     await startInProcess(this);
   });
@@ -69,7 +82,7 @@ describe("UpdateChecks", function () {
     assert.equal(result.latestVersion, "10");
 
     // Also works in post method.
-    const resp2 = await axios.post(`${homeUrl}/api/version`);
+    const resp2 = await axios.post(`${homeUrl}/api/version`, {}, headers);
     assert.equal(resp2.status, 200);
     assert.deepEqual(resp2.data, result);
   });
@@ -197,6 +210,27 @@ describe("UpdateChecks", function () {
     assert.equal(resp.status, 500);
     assert.match(resp.data.error, /timeout/);
   });
+
+  it("logs deploymentId and deploymentType", async function () {
+    logMessages.length = 0;
+    setEndpoint(dockerHub.url + "/tags");
+    const installationId = "randomInstallationId";
+    const deploymentType = "test";
+    const resp = await axios.post(`${homeUrl}/api/version`, {
+      installationId,
+      deploymentType
+    }, chimpy);
+    assert.equal(resp.status, 200);
+    assert.equal(logMessages.length, 1);
+    const [name, meta] = logMessages[0];
+    assert.equal(name, "checkedUpdateAPI");
+    assert.deepEqual(meta, {
+      full: {
+        deploymentId: installationId,
+        deploymentType,
+      },
+    });
+  });
 });
 
 async function dummyDockerHub() {
diff --git a/test/server/Comm.ts b/test/server/Comm.ts
index 51e21a0e..eb979486 100644
--- a/test/server/Comm.ts
+++ b/test/server/Comm.ts
@@ -102,16 +102,6 @@ describe('Comm', function() {
     }
   };
 
-  beforeEach(function() {
-    // Silence console messages from client-side Comm.ts.
-    if (!process.env.VERBOSE) {
-      // TODO: This no longer works, now that 'log' is a more proper "module" object rather than
-      // an arbitrary JS object. Also used in a couple other tests where logs are no longer
-      // silenced.
-      sandbox.stub(log, 'debug');
-    }
-  });
-
   afterEach(async function() {
     // Run the cleanup callbacks registered in cleanup().
     await Promise.all(cleanup.splice(0).map(callback => callback()));
diff --git a/test/server/lib/DocApi.ts b/test/server/lib/DocApi.ts
index b816dc3f..86b515d3 100644
--- a/test/server/lib/DocApi.ts
+++ b/test/server/lib/DocApi.ts
@@ -4029,7 +4029,7 @@ function testDocApi() {
           ...pick(options, 'name', 'memo', 'enabled', 'watchedColIds'),
         }, chimpy
       );
-      assert.equal(status, 200);
+      assert.equal(status, 200, `Error during subscription: ` + JSON.stringify(data));
       return data as WebhookSubscription;
     }
 

From a8431c69a735bf54df80a81134ca059bd8f29c5e Mon Sep 17 00:00:00 2001
From: Spoffy <4805393+Spoffy@users.noreply.github.com>
Date: Thu, 27 Jun 2024 14:24:32 +0100
Subject: [PATCH 015/145] Makes docker images default to non-root execution
 (#1031)

De-escalates to a normal user when the docker image is run as root.

Allows GRIST_DOCKER_USER and GRIST_DOCKER_GROUP to be passed to override the default de-escalation behaviour.

Backwards compatible with previous root installations.

--------

This change adds a new docker_entrypoint.sh, which when run as root de-escalates to the provided user, defaulting to grist:grist. This is similar to the approach used by the official postgres docker image.

To achieve backwards compatibility, it changes ownership of any files in `/persist` to the user it's given at runtime. Since the docker container is typically run as root, this should always work.

If the container is run as a standard user from the very start:
* It's the admin's responsibility to ensure `/persist` is writable by that user.
* `/grist` remains owned by root and is read-only.
---
 Dockerfile                   | 11 +++++++++-
 sandbox/docker_entrypoint.sh | 40 ++++++++++++++++++++++++++++++++++++
 2 files changed, 50 insertions(+), 1 deletion(-)
 create mode 100755 sandbox/docker_entrypoint.sh

diff --git a/Dockerfile b/Dockerfile
index 35148cdb..cdd584f7 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -122,6 +122,15 @@ RUN \
   mv /grist/static-built/* /grist/static && \
   rmdir /grist/static-built
 
+# To ensure non-root users can run grist, 'other' users need read access (and execute on directories)
+# This should be the case by default when copying files in.
+# Only uncomment this if running into permissions issues, as it takes a long time to execute on some systems.
+# RUN chmod -R o+rX /grist
+
+# Add a user to allow de-escalating from root on startup
+RUN useradd -ms /bin/bash grist
+ENV GRIST_DOCKER_USER=grist \
+    GRIST_DOCKER_GROUP=grist
 WORKDIR /grist
 
 # Set some default environment variables to give a setup that works out of the box when
@@ -151,5 +160,5 @@ ENV \
 
 EXPOSE 8484
 
-ENTRYPOINT ["/usr/bin/tini", "-s", "--"]
+ENTRYPOINT ["./sandbox/docker_entrypoint.sh"]
 CMD ["node", "./sandbox/supervisor.mjs"]
diff --git a/sandbox/docker_entrypoint.sh b/sandbox/docker_entrypoint.sh
new file mode 100755
index 00000000..7072e07e
--- /dev/null
+++ b/sandbox/docker_entrypoint.sh
@@ -0,0 +1,40 @@
+#!/usr/bin/env bash
+set -Eeuo pipefail
+
+# Runs the command provided as arguments, but attempts to configure permissions first.
+
+important_read_dirs=("/grist" "/persist")
+write_dir="/persist"
+current_user_id=$(id -u)
+
+# We want to avoid running Grist as root if possible.
+# Try to setup permissions and de-elevate to a normal user.
+if [[ $current_user_id == 0 ]]; then
+  target_user=${GRIST_DOCKER_USER:-grist}
+  target_group=${GRIST_DOCKER_GROUP:-grist}
+
+  # Make sure the target user owns everything that Grist needs write access to.
+  find $write_dir ! -user "$target_user" -exec chown "$target_user" "{}" +
+
+  # Restart as the target user, replacing the current process (replacement is needed for security).
+  # Alternative tools to setpriv are: chroot, gosu.
+  # Need to use `exec` to close the parent shell, to avoid vulnerabilities: https://github.com/tianon/gosu/issues/37
+  exec setpriv --reuid "$target_user" --regid "$target_group" --init-groups /usr/bin/env bash "$0" "$@"
+fi
+
+# Validate that this user has access to the top level of each important directory.
+# There might be a benefit to testing individual files, but this is simpler as the dir may start empty.
+for dir in "${important_read_dirs[@]}"; do
+  if ! { test -r "$dir" ;} ; then
+    echo "Invalid permissions, cannot read '$dir'. Aborting." >&2
+    exit 1
+  fi
+done
+for dir in "${important_write_dirs[@]}"; do
+  if ! { test -r "$dir" && test -w "$dir" ;} ; then
+    echo "Invalid permissions, cannot write '$dir'. Aborting." >&2
+    exit 1
+  fi
+done
+
+exec /usr/bin/tini -s -- "$@"

From 1e5cc585a7b4a69954bcfbd09e76641c007953d9 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= <jordigh@octave.org>
Date: Wed, 26 Jun 2024 17:02:45 -0400
Subject: [PATCH 016/145] workflows: update the latest branch conditionally

Since we now run the build three times, we don't want to update the
latest branch unless all three builds complete successfully.
---
 .github/workflows/docker_latest.yml | 10 ++++++++++
 1 file changed, 10 insertions(+)

diff --git a/.github/workflows/docker_latest.yml b/.github/workflows/docker_latest.yml
index e5b7a5b8..d4539031 100644
--- a/.github/workflows/docker_latest.yml
+++ b/.github/workflows/docker_latest.yml
@@ -107,6 +107,16 @@ jobs:
           cache-to: type=gha,mode=max
           build-contexts: ${{ matrix.image.name != 'grist-oss' && 'ext=ext' || '' }}
 
+  update_latest_branch:
+    name: Update latest branch
+    runs-on: ubuntu-latest
+    needs: push_to_registry
+    steps:
+      - name: Check out the repo
+        uses: actions/checkout@v2
+        with:
+          ref: ${{ inputs.latest_branch }}
+
       - name: Update latest branch
         uses: ad-m/github-push-action@8407731efefc0d8f72af254c74276b7a90be36e1
         with:

From cefadc50c4c7a2a3b32b9cb91edcf45ef4cf6eae Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= <jordigh@octave.org>
Date: Thu, 27 Jun 2024 08:34:19 -0400
Subject: [PATCH 017/145] workflows: ensure we also use the experimental image
 we just built

I think without a tag it defaults to `latest`, which is not what we
want.
---
 .github/workflows/docker_latest.yml | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/.github/workflows/docker_latest.yml b/.github/workflows/docker_latest.yml
index d4539031..4a6f2c1c 100644
--- a/.github/workflows/docker_latest.yml
+++ b/.github/workflows/docker_latest.yml
@@ -33,7 +33,7 @@ jobs:
           - name: "grist"
             repo: "grist-ee"
           # For now, we build it twice, with `grist-ee` being a
-          # backwards-compatible synoym for `grist`.
+          # backwards-compatible synonym for `grist`.
           - name: "grist-ee"
             repo: "grist-ee"
     steps:
@@ -88,7 +88,7 @@ jobs:
           yarn run build:prod
 
       - name: Run tests
-        run: TEST_IMAGE=${{ github.repository_owner }}/${{ matrix.image.name }} VERBOSE=1 DEBUG=1 MOCHA_WEBDRIVER_HEADLESS=1 yarn run test:docker
+        run: TEST_IMAGE=${{ github.repository_owner }}/${{ matrix.image.name }}:experimental VERBOSE=1 DEBUG=1 MOCHA_WEBDRIVER_HEADLESS=1 yarn run test:docker
 
       - name: Log in to Docker Hub
         uses: docker/login-action@v1 

From 6e9dae291c8c251f539d7d9532c6fa05b19627aa Mon Sep 17 00:00:00 2001
From: Riccardo Polignieri <ric.pol@libero.it>
Date: Fri, 28 Jun 2024 20:47:06 +0000
Subject: [PATCH 018/145] Translated using Weblate (Italian)

Currently translated at 100.0% (1337 of 1337 strings)

Translation: Grist/client
Translate-URL: https://hosted.weblate.org/projects/grist/client/it/
---
 static/locales/it.client.json | 52 +++++++++++++++++++++++++++++++----
 1 file changed, 47 insertions(+), 5 deletions(-)

diff --git a/static/locales/it.client.json b/static/locales/it.client.json
index c252e752..bdae0869 100644
--- a/static/locales/it.client.json
+++ b/static/locales/it.client.json
@@ -41,7 +41,8 @@
         "Trash": "Cestino",
         "Workspace will be moved to Trash.": "Lo spazio di lavoro sarà spostato nel cestino.",
         "Workspaces": "Spazi di lavoro",
-        "Tutorial": "Tutorial"
+        "Tutorial": "Tutorial",
+        "Terms of service": "Condizioni di servizio"
     },
     "MakeCopyMenu": {
         "However, it appears to be already identical.": "Tuttavia, sembra essere già identico.",
@@ -549,7 +550,9 @@
         "Legacy": "Vecchia versione",
         "Personal Site": "Sito personale",
         "Team Site": "Sito del team",
-        "Grist Templates": "Template di Grist"
+        "Grist Templates": "Template di Grist",
+        "Billing Account": "Dati di fatturazione",
+        "Manage Team": "Gestisci il team"
     },
     "ChartView": {
         "Create separate series for each value of the selected column.": "Creare serie separate per ciascun valore delle colonne selezionate.",
@@ -726,7 +729,19 @@
         "Currency": "Valuta",
         "Document ID to use whenever the REST API calls for {{docId}}. See {{apiURL}}": "ID Documento da usare quando le Api REST chiedono {{docId}}. Vedi {{apiURL}}",
         "Python version used": "Versione di Python in uso",
-        "Try API calls from the browser": "Prova le chiamate Api nel browser"
+        "Try API calls from the browser": "Prova le chiamate Api nel browser",
+        "Stop timing...": "Ferma cronometro...",
+        "Timing is on": "Cronometro attivo",
+        "You can make changes to the document, then stop timing to see the results.": "Puoi fare modifiche al documento, quindi fermare il cronometro e vedere il risultato.",
+        "Cancel": "Annulla",
+        "Reload data engine?": "Ricarica il motore dati?",
+        "Force reload the document while timing formulas, and show the result.": "Ricarica il documenti cronometrando le formule, e mostra il risultato.",
+        "Formula timer": "Cronometro per le formule",
+        "Reload data engine": "Ricarica il motore dati",
+        "Start timing": "Avvia cronometro",
+        "Time reload": "Ricarica tempo",
+        "Only available to document editors": "Disponibile solo per gli editor del documento",
+        "Only available to document owners": "Disponibile solo per i proprietari del documento"
     },
     "DocumentUsage": {
         "Data Size": "Dimensione dei dati",
@@ -1106,7 +1121,9 @@
         "Add conditional style": "Aggiungi stile condizionale",
         "Error in style rule": "Errore nella regola di stile",
         "Row Style": "Stile riga",
-        "Rule must return True or False": "La regola deve restituire Vero o Falso"
+        "Rule must return True or False": "La regola deve restituire Vero o Falso",
+        "Conditional Style": "Stile condizionale",
+        "IF...": "SE..."
     },
     "CurrencyPicker": {
         "Invalid currency": "Valuta non valida"
@@ -1466,7 +1483,22 @@
         "Check now": "Controlla adesso",
         "Checking for updates...": "Controllo gli aggiornamenti...",
         "Grist releases are at ": "Le release di Grist sono a ",
-        "Sandbox settings for data engine": "Impostazione della sandbox per il motore dati"
+        "Sandbox settings for data engine": "Impostazione della sandbox per il motore dati",
+        "Current authentication method": "Metodo di autenticazione attuale",
+        "No fault detected.": "Nessun problema rilevato.",
+        "Or, as a fallback, you can set: {{bootKey}} in the environment and visit: {{url}}": "O, come fallback, puoi impostare: {{bootKey}} nell'ambiente, e visitare: {{url}}",
+        "Results": "Risultati",
+        "Self Checks": "Auto-diagnostica",
+        "You do not have access to the administrator panel.\nPlease log in as an administrator.": "Non hai accesso al pannello di amministrazione.\nAccedi come amministratore.",
+        "Grist allows different types of authentication to be configured, including SAML and OIDC. We recommend enabling one of these if Grist is accessible over the network or being made available to multiple people.": "Grist consente di configurare diversi tipi di autenticazione, compresi SAML e OIDC. Raccomandiamo di attivarne uno, se Grist è dispobile in rete, o raggiungibile da più persone.",
+        "Details": "Dettagli",
+        "Notes": "Note",
+        "Grist signs user session cookies with a secret key. Please set this key via the environment variable GRIST_SESSION_SECRET. Grist falls back to a hard-coded default when it is not set. We may remove this notice in the future since session IDs have been updated to be inherently cryptographically secure.": "Grist firma i cookie della sessione con una chiave segreta. Impostare questa chiave con la variabile d'ambiente GRIST_SESSION_SECRET. Se questa non è definita, Grist usa un default non modificabile. Potremmo rimuovere questo avviso in futuro, perché gli ID di sessione generati a partire dalla versione 1.1.16 sono intrinsecamente sicuri dal punto di vista crittografico.",
+        "Administrator Panel Unavailable": "Pannello di amministrazione non disponibile",
+        "Authentication": "Autenticazione",
+        "Check failed.": "Controllo fallito.",
+        "Check succeeded.": "Controllo riuscito.",
+        "Grist allows different types of authentication to be configured, including SAML and OIDC.     We recommend enabling one of these if Grist is accessible over the network or being made available     to multiple people.": "Grist consente di configurare diversi tipi di autenticazione, compresi SAML e OIDC.     Raccomandiamo di attivarne uno, se Grist è dispobile in rete, o raggiungibile      da più persone."
     },
     "WelcomeCoachingCall": {
         "Maybe Later": "Forse più tardi",
@@ -1587,5 +1619,15 @@
         "Chart": "Grafico",
         "Custom": "Personalizzato",
         "Form": "Modulo"
+    },
+    "TimingPage": {
+        "Average Time (s)": "Media tempi (sec)",
+        "Total Time (s)": "Tempo totale (sec)",
+        "Loading timing data. Don't close this tab.": "Caricamento dei dati cronometrici. Non chiudere questa scheda.",
+        "Max Time (s)": "Tempo massimo (sec)",
+        "Number of Calls": "Numero di chiamate",
+        "Table ID": "ID Tabella",
+        "Column ID": "ID colonna",
+        "Formula timer": "Cronometro formule"
     }
 }

From 994432e5dec168b6107fd037fc4e5b67a976c78f Mon Sep 17 00:00:00 2001
From: Roman Holinec <3ko@pixeon.sk>
Date: Fri, 28 Jun 2024 10:38:18 +0000
Subject: [PATCH 019/145] Translated using Weblate (Slovak)

Currently translated at 100.0% (1337 of 1337 strings)

Translation: Grist/client
Translate-URL: https://hosted.weblate.org/projects/grist/client/sk/
---
 static/locales/sk.client.json | 1065 ++++++++++++++++++++++++++++++++-
 1 file changed, 1061 insertions(+), 4 deletions(-)

diff --git a/static/locales/sk.client.json b/static/locales/sk.client.json
index b5b72d72..7bc059b3 100644
--- a/static/locales/sk.client.json
+++ b/static/locales/sk.client.json
@@ -67,9 +67,9 @@
         "Accounts": "Účty",
         "Add Account": "Pridať účet",
         "Document Settings": "Nastavenie Dokumentu",
-        "Manage Team": "Spravovať tím",
+        "Manage Team": "Riadiť Tím",
         "Pricing": "Cenník",
-        "Profile Settings": "Nastavenie profilu",
+        "Profile Settings": "Nastavenie Profilu",
         "Sign Out": "Odhlásiť sa",
         "Sign in": "Prihlásiť sa",
         "Switch Accounts": "Prepnúť Účty",
@@ -375,7 +375,7 @@
     },
     "FieldConfig": {
         "COLUMN BEHAVIOR": "SPRÁVANIE STĹPCA",
-        "COLUMN LABEL AND ID": "ŠTÍTOK A ID STĹPCA",
+        "COLUMN LABEL AND ID": "OZNAČENIE A ID STĹPCA",
         "Clear and make into formula": "Vyčistiť a vytvoriť vzorec",
         "DESCRIPTION": "POPIS",
         "Clear and reset": "Vymazať a resetovať",
@@ -545,7 +545,29 @@
         "Be careful, the original has changes not in this document. Those changes will be overwritten.": "Buďte opatrní, originál obsahuje zmeny, ktoré nie sú v tomto dokumente. Tieto zmeny budú prepísané.",
         "However, it appears to be already identical.": "Zdá sa však, že je už identický.",
         "Include the structure without any of the data.": "Zahrňte štruktúru bez akýchkoľvek údajov.",
-        "Cancel": "Zrušiť"
+        "Cancel": "Zrušiť",
+        "You do not have write access to the selected workspace": "Nemáte prístup na zápis do vybratého pracovného priestoru",
+        "It will be overwritten, losing any content not in this document.": "Bude prepísaný, pričom sa stratí všetok obsah, ktorý nie je v tomto dokumente.",
+        "Name": "Meno",
+        "You do not have write access to this site": "Nemáte prístup k zápisu na túto stránku",
+        "No destination workspace": "Žiadny cieľový pracovný priestor",
+        "Organization": "Organizácia",
+        "Overwrite": "Prepísať",
+        "Sign up": "Prihlásiť sa",
+        "Download full document and history": "Stiahnite si celý dokument a jeho históriu",
+        "Remove all data but keep the structure to use as a template": "Odstráňte všetky údaje, ale ponechajte štruktúru na použitie ako šablónu",
+        "Remove document history (can significantly reduce file size)": "Odstrániť históriu dokumentov (môže výrazne znížiť veľkosť súboru)",
+        "Download": "Stiahnuť",
+        "Download document": "Stiahnuť dokument",
+        "Original Has Modifications": "Originál Má Úpravy",
+        "Original Looks Unrelated": "Pôvodný Pohľad Nesúvisí",
+        "Original Looks Identical": "Pôvodný Pohľad Identický",
+        "Replacing the original requires editing rights on the original document.": "Nahradenie originálu vyžaduje práva na úpravu pôvodného dokumentu.",
+        "The original version of this document will be updated.": "Pôvodná verzia tohto dokumentu bude aktualizovaná.",
+        "To save your changes, please sign up, then reload this page.": "Ak chcete uložiť zmeny, zaregistrujte sa a potom znova načítajte túto stránku.",
+        "Update": "Aktualizovať",
+        "Update Original": "Aktualizovať Originál",
+        "Workspace": "Pracovný priestor"
     },
     "GristDoc": {
         "Added new linked section to view {{viewName}}": "Pridaná nová prepojená sekcia na zobrazenie {{viewName}}",
@@ -572,5 +594,1040 @@
     },
     "LeftPanelCommon": {
         "Help Center": "Centrum Pomoci"
+    },
+    "NotifyUI": {
+        "Go to your free personal site": "Prejdite na svoju bezplatnú osobnú stránku",
+        "No notifications": "Žiadne upozornenia",
+        "Notifications": "Upozornenia",
+        "Manage billing": "Spravovať fakturáciu",
+        "Ask for help": "Požiadať o pomoc",
+        "Cannot find personal site, sorry!": "Nie je možné nájsť osobnú stránku, prepáčte!",
+        "Give feedback": "Dať spätnú väzbu",
+        "Renew": "Obnoviť",
+        "Report a problem": "Nahlásiť problém",
+        "Upgrade Plan": "Plán Inovácie"
+    },
+    "OpenVideoTour": {
+        "YouTube video player": "Prehrávač videa YouTube",
+        "Grist Video Tour": "Grist Video Prehliadka",
+        "Video Tour": "Video Prehliadka"
+    },
+    "PageWidgetPicker": {
+        "Add to Page": "Pridať na Stránku",
+        "Group by": "Zoskupiť podľa",
+        "Building {{- label}} widget": "Vytvoriť miniaplikáciu {{- label}}",
+        "Select Data": "Vybrať Údaje",
+        "Select Widget": "Vybrať Miniaplikáciu"
+    },
+    "RecordLayout": {
+        "Updating record layout.": "Aktualizuje sa schéma záznamov."
+    },
+    "Pages": {
+        "The following tables will no longer be visible_one": "Nasledujúca tabuľka už nebude zobraziteľná",
+        "The following tables will no longer be visible_other": "Nasledujúce tabuľky už nebudú zobraziteľné",
+        "Delete": "Odstrániť",
+        "Delete data and this page.": "Odstrániť údaje a túto stránku."
+    },
+    "PermissionsWidget": {
+        "Deny All": "Odmietnuť Všetko",
+        "Read Only": "Iba na čítanie",
+        "Allow All": "Povoliť Všetko"
+    },
+    "PluginScreen": {
+        "Import failed: ": "Import zlyhal: "
+    },
+    "RecordLayoutEditor": {
+        "Save Layout": "Uložiť Rozloženie",
+        "Cancel": "Zrušiť",
+        "Add Field": "Pridať pole",
+        "Create New Field": "Vytvoriť nové pole",
+        "Show field {{- label}}": "Zobraziť pole {{- label}}"
+    },
+    "RefSelect": {
+        "Add Column": "Pridať stĺpec",
+        "No columns to add": "Žiadne stĺpce na pridanie"
+    },
+    "RightPanel": {
+        "CHART TYPE": "TYP GRAF",
+        "Data": "Údaje",
+        "Fields_one": "Pole",
+        "Fields_other": "Polia",
+        "Sort & Filter": "Triediť a Filtrovať",
+        "TRANSFORM": "TRANSFORMÁCIA",
+        "Theme": "Téma",
+        "WIDGET TITLE": "NÁZOV MINIAPLIKÁCIE",
+        "Enter redirect URL": "Zadať URL presmerovania",
+        "No field selected": "Nie je vybraté žiadne pole",
+        "COLUMN TYPE": "TYP STĹPEC",
+        "CUSTOM": "VOLITELNÝ",
+        "Change Widget": "Zmeniť Miniaplikáciu",
+        "Columns_one": "Stĺpec",
+        "Columns_other": "Stĺpce",
+        "DATA TABLE": "TABUĽKA ÚDAJOV",
+        "DATA TABLE NAME": "NÁZOV TABUĽKY ÚDAJOV",
+        "Detach": "Oddeliť",
+        "Edit Data Selection": "Upraviť Výber Údajov",
+        "GROUPED BY": "ZOSKUPIŤ PODĽA",
+        "ROW STYLE": "ŠTÝL RIADKU",
+        "Row Style": "Štýl Riadku",
+        "SELECT BY": "VYBRAŤ PODĽA",
+        "SELECTOR FOR": "VOLIČ PRE",
+        "SOURCE DATA": "ZDROJOVÉ ÚDAJE",
+        "Save": "Uložiť",
+        "Select Widget": "Vybrať Miniaplikáciu",
+        "Series_one": "Séria",
+        "Series_other": "Série",
+        "Widget": "Miniaplikácia",
+        "You do not have edit access to this document": "Nemáte prístup k úpravám tohto dokumentu",
+        "Add referenced columns": "Pridať referenčné stĺpce",
+        "Reset form": "Obnoviť formulár",
+        "Configuration": "Konfigurácia",
+        "Default field value": "Predvolená hodnota poľa",
+        "Display button": "Tlačidlo Zobrazenia",
+        "Enter text": "Zadať text",
+        "Required field": "Vyžadované pole",
+        "Field rules": "Pravidlá poľa",
+        "Field title": "Názov Poľa",
+        "Hidden field": "Skryté pole",
+        "Layout": "Rozloženie",
+        "Redirect automatically after submission": "Po odoslaní automaticky presmerovať",
+        "Redirection": "Presmerovať",
+        "Submission": "Návrh",
+        "Submit another response": "Odoslať inú odpoveď",
+        "Success text": "Text Dokončenia",
+        "Table column name": "Názov stĺpca tabuľky",
+        "Select a field in the form widget to configure.": "Vyberte pole v miniaplikácii formulára, ktoré chcete nakonfigurovať.",
+        "Submit button label": "Označenie tlačidla Odoslať"
+    },
+    "RowContextMenu": {
+        "Copy anchor link": "Kopírovať odkaz na kotvu",
+        "Insert row": "Vložiť riadok",
+        "Insert row above": "Vložiť riadok vyššie",
+        "Duplicate rows_other": "Duplikovať riadky",
+        "Insert row below": "Vložiť riadok nižšie",
+        "Delete": "Odstrániť",
+        "Duplicate rows_one": "Duplikovať riadok",
+        "View as card": "Zobraziť ako kartu",
+        "Use as table headers": "Použiť ako hlavičky tabuľky"
+    },
+    "ShareMenu": {
+        "Edit without affecting the original": "Upraviť bez ovplyvnenia originálu",
+        "Duplicate Document": "Duplikovať Dokument",
+        "Back to Current": "Späť na Aktuálne",
+        "Compare to {{termToUse}}": "Porovnať s {{termToUse}}",
+        "Access Details": "Podrobnosti Prístupu",
+        "Current Version": "Aktuálna verzia",
+        "Download": "Stiahnuť",
+        "Export CSV": "Exportovať CSV",
+        "Export XLSX": "Exportovať XLSX",
+        "Send to Google Drive": "Odoslať na Disk Google",
+        "Show in folder": "Zobraziť v priečinku",
+        "Unsaved": "Neuložené",
+        "Work on a Copy": "Pracovať na kópii",
+        "Share": "Zdieľať",
+        "Download...": "Stiahnuť ...",
+        "Comma Separated Values (.csv)": "Hodnoty oddelené čiarkou (.csv)",
+        "DOO Separated Values (.dsv)": "DOO Separated Values (.dsv)",
+        "Export as...": "Exportovať ako...",
+        "Microsoft Excel (.xlsx)": "Microsoft Excel (.xlsx)",
+        "Tab Separated Values (.tsv)": "Hodnoty oddelené tabulátorom (.tsv)",
+        "Manage Users": "Spravovať Používateľov",
+        "Replace {{termToUse}}...": "Nahradiť {{termToUse}}…",
+        "Original": "Originál",
+        "Return to {{termToUse}}": "Späť na {{termToUse}}",
+        "Save Copy": "Uložiť kópiu",
+        "Save Document": "Uložiť Dokument"
+    },
+    "Tools": {
+        "Delete document tour?": "Chcete odstrániť Prehliadku?",
+        "How-to Tutorial": "Návod Ako na to",
+        "Code View": "Zobrazenie Kódu",
+        "Delete": "Odstrániť",
+        "Document History": "História Dokumentu",
+        "Settings": "Nastavenie",
+        "API Console": "API Konzola",
+        "Tour of this Document": "Prehliadka tohto Dokumentu",
+        "Validate Data": "Overiť Údaje",
+        "Access Rules": "Pravidlá Prístupu",
+        "Raw Data": "Nespracované Údaje",
+        "Return to viewing as yourself": "Vráťiť sa ku svojmu zobrazeniu",
+        "TOOLS": "NÁSTROJE"
+    },
+    "TopBar": {
+        "Manage Team": "Riadiť Tím"
+    },
+    "TriggerFormulas": {
+        "Any field": "Akékoľvek pole",
+        "Apply to new records": "Použiť na nové záznamy",
+        "Apply on record changes": "Použiť na zmeny záznamu",
+        "OK": "OK",
+        "Current field ": "Aktuálne pole ",
+        "Apply on changes to:": "Použiť pri zmenách na:",
+        "Cancel": "Zrušiť",
+        "Close": "Zavrieť"
+    },
+    "TypeTransformation": {
+        "Apply": "Použiť",
+        "Cancel": "Zrušiť",
+        "Preview": "Náhľad",
+        "Revise": "Revidovať",
+        "Update formula (Shift+Enter)": "Aktualizovať vzorec (Shift+Enter)"
+    },
+    "UserManagerModel": {
+        "Editor": "Editor",
+        "In Full": "Plne",
+        "No Default Access": "Žiadny Predvolený Prístup",
+        "None": "Žiadne",
+        "Owner": "Vlastník",
+        "View & Edit": "Zobraziť a Upraviť",
+        "View Only": "Iba Zobraziť",
+        "Viewer": "Divák"
+    },
+    "ViewConfigTab": {
+        "Plugin: ": "Plugin: ",
+        "Make On-Demand": "Urobiť Na Požiadanie (On-Demand)",
+        "Advanced settings": "Pokročilé nastavenia",
+        "Big tables may be marked as \"on-demand\" to avoid loading them into the data engine.": "Veľké tabuľky môžu byť označené ako „on-demand“, aby sa predišlo ich načítaniu do dátového stroja.",
+        "Blocks": "Bloky",
+        "Compact": "Kompaktný",
+        "Edit Card Layout": "Upraviť Rozloženie Karty",
+        "Form": "Formulár",
+        "Section: ": "Sekcia: ",
+        "Unmark On-Demand": "Odobrať označenie On-Demand"
+    },
+    "ViewLayoutMenu": {
+        "Widget options": "Možnosti miniaplikácie",
+        "Advanced Sort & Filter": "Pokročilé Triedenie a Filtrovanie",
+        "Copy anchor link": "Kopírovať odkaz na kotvu",
+        "Data selection": "Výber údajov",
+        "Delete record": "Odstrániť záznam",
+        "Delete widget": "Odstrániť miniaplikáciu",
+        "Download as CSV": "Stiahnuť ako CSV",
+        "Download as XLSX": "Stiahnuť ako XLSX",
+        "Edit Card Layout": "Upraviť Rozloženie Karty",
+        "Open configuration": "Otvorte konfiguráciu",
+        "Print widget": "Tlačiť miniaplikáciu",
+        "Show raw data": "Zobraziť nespracované údaje",
+        "Collapse widget": "Zbaliť miniaplikáciu",
+        "Create a form": "Vytvoriť formulár",
+        "Add to page": "Pridať na stránku"
+    },
+    "ViewSectionMenu": {
+        "SORT": "TRIEDIŤ",
+        "(customized)": "(prispôsobené)",
+        "(empty)": "(prázdne)",
+        "(modified)": "(zmenené)",
+        "Custom options": "Vlastné možnosti",
+        "FILTER": "FILTER",
+        "Revert": "Návrat",
+        "Save": "Uložiť",
+        "Update Sort&Filter settings": "Aktualizovať nastavenie triedenia a filtrovania"
+    },
+    "WelcomeQuestions": {
+        "Education": "Vzdelávanie",
+        "Finance & Accounting": "Financie a Účtovníctvo",
+        "HR & Management": "HR a Manažment",
+        "Marketing": "Marketing",
+        "Media Production": "Mediálna Produkcia",
+        "Other": "Ostatné",
+        "Product Development": "Vývoj Produktov",
+        "Research": "Výskum",
+        "Sales": "Predaj",
+        "Type here": "Písať sem",
+        "Welcome to Grist!": "Vitajte v Grist!",
+        "IT & Technology": "IT a Technológie",
+        "What brings you to Grist? Please help us serve you better.": "Čo vás privádza do Gristu? Pomôžte nám, aby sme Vám lepšie poslúžili."
+    },
+    "WidgetTitle": {
+        "Save": "Uložiť",
+        "Cancel": "Zrušiť",
+        "DATA TABLE NAME": "NÁZOV TABUĽKY ÚDAJOV",
+        "Override widget title": "Prepísať názov miniaplikácie",
+        "Provide a table name": "Zadajte názov tabuľky",
+        "WIDGET TITLE": "NÁZOV MINIAPLIKÁCIE",
+        "WIDGET DESCRIPTION": "POPIS MINIAPLIKÁCIE"
+    },
+    "duplicatePage": {
+        "Note that this does not copy data, but creates another view of the same data.": "Upozornenie, že sa nekopírujú údaje, ale sa vytvorí iné zobrazenie rovnakých údajov.",
+        "Duplicate page {{pageName}}": "Duplikovať stránku {{pageName}}"
+    },
+    "errorPages": {
+        "The requested page could not be found.{{separator}}Please check the URL and try again.": "Požadovanú stránku sa nepodarilo nájsť.{{separator}}Skontrolujte adresu URL a skúste to znova.",
+        "Sign up": "Prihlásiť sa",
+        "Powered by": "Poháňaný",
+        "You are signed in as {{email}}. You can sign in with a different account, or ask an administrator for access.": "Ste prihlásený/-á ako {{email}}. Môžete sa prihlásiť pomocou iného účtu alebo požiadať správcu o prístup.",
+        "Form not found": "Formulár sa nenašiel",
+        "Access denied{{suffix}}": "Prístup odmietnutý {{suffix}}",
+        "Add account": "Pridať účet",
+        "Contact support": "Kontaktovať podporu",
+        "Error{{suffix}}": "Chyba {{suffix}}",
+        "Go to main page": "Prejdite na hlavnú stránku",
+        "Page not found{{suffix}}": "Stránka sa nenašla {{suffix}}",
+        "Sign in": "Prihlásiť sa",
+        "Sign in again": "Znovu sa prihlásiť",
+        "Sign in to access this organization's documents.": "Prihláste sa, ak chcete získať prístup k dokumentom tejto organizácie.",
+        "Signed out{{suffix}}": "Odhlásený {{suffix}}",
+        "Something went wrong": "Niečo sa pokazilo",
+        "There was an error: {{message}}": "Vyskytla sa chyba: {{message}}",
+        "There was an unknown error.": "Vyskytla sa neznáma chyba.",
+        "You are now signed out.": "Teraz ste odhlásení.",
+        "You do not have access to this organization's documents.": "Nemáte prístup k dokumentom tejto organizácie.",
+        "Account deleted{{suffix}}": "Účet odstránený {{suffix}}",
+        "Your account has been deleted.": "Váš účet bol odstránený.",
+        "An unknown error occurred.": "Vyskytla sa neznáma chyba.",
+        "Build your own form": "Vytvorte si vlastný formulár"
+    },
+    "menus": {
+        "Select fields": "Vybrať polia",
+        "Any": "Akýkoľvek",
+        "Numeric": "Desatinné číslo",
+        "* Workspaces are available on team plans. ": "* Pracovné priestory sú k dispozícii v tímových plánoch. ",
+        "Upgrade now": "Vylepšiť teraz",
+        "Text": "Text",
+        "Integer": "Celé číslo",
+        "Toggle": "Prepínač",
+        "Date": "Dátum",
+        "DateTime": "Dátum a Čas",
+        "Choice": "Voľba",
+        "Choice List": "Zoznam Volieb",
+        "Reference": "Referencia",
+        "Attachment": "Príloha",
+        "Reference List": "Zoznam Referencií",
+        "Search columns": "Hľadať stĺpce"
+    },
+    "modals": {
+        "Save": "Uložiť",
+        "Delete": "Odstrániť",
+        "Dismiss": "Odmietnuť",
+        "Undo to restore": "Späť na obnovenie",
+        "Cancel": "Zrušiť",
+        "Got it": "Mám to",
+        "Ok": "OK",
+        "Don't show again": "Už nezobrazovať",
+        "TIP": "TIP",
+        "Are you sure you want to delete these records?": "Naozaj chcete odstrániť tieto záznamy?",
+        "Are you sure you want to delete this record?": "Naozaj chcete odstrániť tento záznam?",
+        "Don't ask again.": "Už sa nepýtať.",
+        "Don't show again.": "Už nezobrazovať.",
+        "Don't show tips": "Nezobrazovať tipy"
+    },
+    "NTextBox": {
+        "Lines": "Linie",
+        "false": "nepravda",
+        "true": "pravda",
+        "Field Format": "Formát Poľa",
+        "Multi line": "Viac riadok",
+        "Single line": "Jeden riadok"
+    },
+    "TypeTransform": {
+        "Preview": "Náhľad",
+        "Revise": "Revidovať",
+        "Update formula (Shift+Enter)": "Aktualizovať vzorec (Shift+Enter)",
+        "Apply": "Použiť",
+        "Cancel": "Zrušiť"
+    },
+    "ColumnInfo": {
+        "COLUMN ID: ": "ID STĹPCA: ",
+        "Cancel": "Zrušiť",
+        "Save": "Uložiť",
+        "COLUMN DESCRIPTION": "POPIS STĹPCA",
+        "COLUMN LABEL": "OZNAČENIE STĹPCA"
+    },
+    "ConditionalStyle": {
+        "Row Style": "Štýl Riadku",
+        "Rule must return True or False": "Pravidlo musí vrátiť hodnotu Pravda alebo Nepravda",
+        "Conditional Style": "Podmienený Štýl",
+        "Add another rule": "Pridať ďalšie pravidlo",
+        "Add conditional style": "Pridať podmienený štýl",
+        "Error in style rule": "Chyba v pravidle štýlu",
+        "IF...": "AK..."
+    },
+    "DiscussionEditor": {
+        "Resolve": "Vyriešiť",
+        "Cancel": "Zrušiť",
+        "Comment": "Komentovať",
+        "Edit": "Upraviť",
+        "Marked as resolved": "Označené ako vyriešené",
+        "Only current page": "Iba aktuálna stránka",
+        "Only my threads": "Iba moje vlákna",
+        "Open": "Otvoriť",
+        "Remove": "Odobrať",
+        "Reply": "Odpovedať",
+        "Reply to a comment": "Odpovedať na komentár",
+        "Save": "Uložiť",
+        "Show resolved comments": "Zobraziť vyriešené komentáre",
+        "Showing last {{nb}} comments": "Zobrazujú sa posledné {{nb}} komentáre",
+        "Started discussion": "Začať diskusiu",
+        "Write a comment": "Napísať komentár"
+    },
+    "FieldBuilder": {
+        "Changing column type": "Zmena typu stĺpca",
+        "DATA FROM TABLE": "ÚDAJE Z TABUĽKY",
+        "Mixed format": "Zmiešaný formát",
+        "Mixed types": "Zmiešané typy",
+        "Revert field settings for {{colId}} to common": "Vrátiť nastavenie poľa pre {{colId}} na bežné",
+        "Save field settings for {{colId}} as common": "Uložiť nastavenie poľa pre {{colId}} ako bežné",
+        "Use separate field settings for {{colId}}": "Použiť samostatné nastavenie poľa pre {{colId}}",
+        "Apply Formula to Data": "Použiť Vzorec na Údaje",
+        "CELL FORMAT": "FORMÁT BUNIEK",
+        "Changing multiple column types": "Zmena viacerých typov stĺpcov"
+    },
+    "FieldEditor": {
+        "Unable to finish saving edited cell": "Ukladanie upravenej bunky nie je možné dokončiť",
+        "It should be impossible to save a plain data value into a formula column": "Malo by byť nemožné uložiť obyčajnú hodnotu údajov do stĺpca vzorca"
+    },
+    "NumericTextBox": {
+        "Currency": "Mena",
+        "Decimals": "Desatinné čísla",
+        "Number Format": "Formát Čísla",
+        "Default currency ({{defaultCurrency}})": "Predvolená mena ({{defaultCurrency}})",
+        "Field Format": "Formát Poľa",
+        "Spinner": "Spinner",
+        "Text": "Text",
+        "max": "max",
+        "min": "min"
+    },
+    "WelcomeTour": {
+        "Add New": "Pridať Nové",
+        "Configuring your document": "Konfigurácia dokumentu",
+        "Editing Data": "Úprava Údajov",
+        "Reference": "Referencia",
+        "Share": "Zdieľať",
+        "Start with {{equal}} to enter a formula.": "Ak chcete zadať vzorec, začnite s {{equal}}.",
+        "Toggle the {{creatorPanel}} to format columns, ": "Prepnite {{creatorPanel}} na formátovanie stĺpcov, ",
+        "Browse our {{templateLibrary}} to discover what's possible and get inspired.": "Prezrite si našu {{templateLibrary}}, zistite, čo je možné a nechajte sa inšpirovať.",
+        "Enter": "Enter",
+        "Flying higher": "Letieť vyššie",
+        "Help Center": "Centrum pomoci",
+        "Make it relational! Use the {{ref}} type to link tables. ": "Urobte z toho vzťah! Na prepojenie tabuliek použite typ {{ref}}. ",
+        "Set formatting options, formulas, or column types, such as dates, choices, or attachments. ": "Nastavte možnosti formátovania, vzorce alebo typy stĺpcov, ako sú dátumy, voľby alebo prílohy. ",
+        "Sharing": "Zdieľanie",
+        "Use the Share button ({{share}}) to share the document or export data.": "Na zdieľanie dokumentu alebo export údajov použite tlačidlo Zdieľať ({{share}}).",
+        "Use {{addNew}} to add widgets, pages, or import more data. ": "Ak chcete pridať miniaplikácie, stránky alebo importovať ďalšie údaje, použite {{addNew}}. ",
+        "Use {{helpCenter}} for documentation or questions.": "V prípade otázok, alebo potreby dokumentácie použite {{helpCenter}}.",
+        "Welcome to Grist!": "Vitajte v Grist!",
+        "convert to card view, select data, and more.": "previesť na zobrazenie kariet, výber údajov a ďalšie.",
+        "creator panel": "panel tvorcu",
+        "template library": "knižnica šablón",
+        "Building up": "Vybudovanie",
+        "Customizing columns": "Prispôsobenie stĺpcov",
+        "Double-click or hit {{enter}} on a cell to edit it. ": "Ak chcete bunku upraviť, dvakrát kliknite na bunku, alebo stlačte {{enter}}. "
+    },
+    "Reference": {
+        "SHOW COLUMN": "ZOBRAZIŤ STĹPEC",
+        "CELL FORMAT": "FORMÁT BUNKY",
+        "Row ID": "ID Riadku"
+    },
+    "GristTooltips": {
+        "Learn more.": "Naučiť sa viac.",
+        "Editing Card Layout": "Úprava Rozloženia Karty",
+        "Nested Filtering": "Vnorené Filtrovanie",
+        "Updates every 5 minutes.": "Aktualizácie každých 5 minút.",
+        "Use the \\u{1D6BA} icon to create summary (or pivot) tables, for totals or subtotals.": "Pomocou ikony \\u{1D6BA} vytvorte súhrnné (alebo kontingenčné) tabuľky pre súčty alebo medzisúčty.",
+        "Custom Widgets": "Vlastná Miniaplikácia",
+        "Use the 𝚺 icon to create summary (or pivot) tables, for totals or subtotals.": "Pomocou ikony 𝚺 vytvorte súhrnné (alebo kontingenčné) tabuľky pre súčty alebo medzisúčty.",
+        "Anchor Links": "Odkaz na Kotvu",
+        "Can't find the right columns? Click 'Change Widget' to select the table with events data.": "Neviete nájsť správne stĺpce? Kliknite na „Zmeniť miniaplikáciu“ a vyberte tabuľku s údajmi udalostí.",
+        "Example: {{example}}": "Príklad: {{example}}",
+        "Build simple forms right in Grist and share in a click with our new widget. {{learnMoreButton}}": "Vytvárajte jednoduché formuláre priamo v Grist a zdieľajte ich jediným kliknutím pomocou nášho nového widgetu. {{learnMoreButton}}",
+        "Clicking {{EyeHideIcon}} in each cell hides the field from this view without deleting it.": "Kliknutím na {{EyeHideIcon}} v každej bunke skryjete pole v tomto zobrazení bez toho, aby ste ho odstránili.",
+        "Link your new widget to an existing widget on this page.": "Prepojte svoju novú miniaplikáciu s existujúcou miniaplikáciou na tejto stránke.",
+        "Select the table containing the data to show.": "Vyberte tabuľku obsahujúcu údaje, ktoré chcete zobraziť.",
+        "Selecting Data": "Výber Údajov",
+        "The Raw Data page lists all data tables in your document, including summary tables and tables not included in page layouts.": "Na stránke Nespracované Údaje sú uvedené všetky tabuľky s údajmi vo vašom dokumente vrátane súhrnných tabuliek a tabuliek, ktoré nie sú zahrnuté v rozložení strán.",
+        "To make an anchor link that takes the user to a specific cell, click on a row and press {{shortcut}}.": "Ak chcete vytvoriť kotviaci odkaz, ktorý používateľa zavedie do konkrétnej bunky, kliknite na riadok a stlačte {{shortcut}}.",
+        "You can choose from widgets available to you in the dropdown, or embed your own by providing its full URL.": "Môžete si vybrať z miniaplikácií, ktoré máte k dispozícii v rozbaľovacej ponuke, alebo môžete vložiť svoje vlastné poskytnutím ich úplnej adresy URL.",
+        "Formulas support many Excel functions, full Python syntax, and include a helpful AI Assistant.": "Vzorce podporujú mnoho funkcií Excelu, plnú syntax Pythonu a zahŕňajú užitočného asistenta UI.",
+        "Apply conditional formatting to cells in this column when formula conditions are met.": "Použiť podmienené formátovanie na bunky v tomto stĺpci, keď sú splnené podmienky vzorca.",
+        "Apply conditional formatting to rows based on formulas.": "Použite podmienené formátovanie na riadky založené na vzorcoch.",
+        "Cells in a reference column always identify an {{entire}} record in that table, but you may select which column from that record to show.": "Bunky v referenčnom stĺpci vždy identifikujú {{entire}} záznam v tejto tabuľke, ale môžete si vybrať, ktorý stĺpec z tohto záznamu sa má zobraziť.",
+        "Click on “Open row styles” to apply conditional formatting to rows.": "Kliknutím na „Otvoriť štýly riadkov“ použijete na riadky podmienené formátovanie.",
+        "Click the Add New button to create new documents or workspaces, or import data.": "Kliknutím na tlačidlo Pridať nový vytvoríte nové dokumenty, pracovné priestory alebo importujete údaje.",
+        "Formulas that trigger in certain cases, and store the calculated value as data.": "Vzorce, ktoré sa v určitých prípadoch spúšťajú a ukladajú vypočítanú hodnotu ako údaje.",
+        "Only those rows will appear which match all of the filters.": "Zobrazia sa len tie riadky, ktoré zodpovedajú všetkým filtrom.",
+        "Pinned filters are displayed as buttons above the widget.": "Pripnuté filtre sa zobrazujú ako tlačidlá nad miniaplikáciou.",
+        "Pinning Filters": "Pripnúť Filtre",
+        "Raw Data page": "Stránka Nespracovaných Údajov",
+        "Rearrange the fields in your card by dragging and resizing cells.": "Usporiadajte polia na karte presunutím buniek a zmenou ich veľkosti.",
+        "Reference Columns": "Referenčné Stĺpce",
+        "Reference columns are the key to {{relational}} data in Grist.": "Referenčné stĺpce sú kľúčom k údajom {{relational}} v Grist.",
+        "Select the table to link to.": "Vyberte tabuľku, na ktorú chcete odkazovať.",
+        "They allow for one record to point (or refer) to another.": "Umožňujú, aby jeden záznam ukazoval (alebo odkazoval) na iný.",
+        "This is the secret to Grist's dynamic and productive layouts.": "Toto je tajomstvo dynamických a produktívnych rozložení Grist.",
+        "Try out changes in a copy, then decide whether to replace the original with your edits.": "Vyskúšajte zmeny v kópii a potom sa rozhodnite, či nahradíte originál vašimi úpravami.",
+        "Unpin to hide the the button while keeping the filter.": "Odopnutím skryjete tlačidlo pri zachovaní filtra.",
+        "Useful for storing the timestamp or author of a new record, data cleaning, and more.": "Užitočné na ukladanie časovej pečiatky alebo autora nového záznamu, čistenie dát a pod.",
+        "You can filter by more than one column.": "Môžete filtrovať podľa viac ako jedného stĺpca.",
+        "entire": "celý",
+        "relational": "vo vzťahu",
+        "Access Rules": "Pravidlá Prístupu",
+        "Access rules give you the power to create nuanced rules to determine who can see or edit which parts of your document.": "Pravidlá prístupu vám umožňujú vytvárať jemné pravidlá na určenie toho, kto môže vidieť alebo upravovať časti vášho dokumentu.",
+        "Add New": "Pridať Nové",
+        "You can choose one of our pre-made widgets or embed your own by providing its full URL.": "Môžete si vybrať jednu z našich vopred pripravených miniaplikácií alebo vložiť svoj vlastnú poskytnutím jeho úplnej adresy URL.",
+        "Calendar": "Kalendár",
+        "To configure your calendar, select columns for start": {
+            "end dates and event titles. Note each column's type.": "Ak chcete nakonfigurovať svoj kalendár, vyberte stĺpce pre dátumy začiatku/ukončenia a názvy udalostí. Všimnite si typ každého stĺpca."
+        },
+        "A UUID is a randomly-generated string that is useful for unique identifiers and link keys.": "UUID je náhodne vygenerovaný reťazec, ktorý je užitočný pre jedinečné identifikátory a kľúče odkazov.",
+        "Lookups return data from related tables.": "Vyhľadávania vracajú údaje zo súvisiacich tabuliek.",
+        "Use reference columns to relate data in different tables.": "Použite referenčné stĺpce na prepojenie údajov v rôznych tabuľkách.",
+        "These rules are applied after all column rules have been processed, if applicable.": "Tieto pravidlá sa použijú po spracovaní všetkých pravidiel stĺpcov, ak je to potrebné.",
+        "Filter displayed dropdown values with a condition.": "Filtrujte zobrazené rozbaľovacie hodnoty s podmienkou.",
+        "Forms are here!": "Formuláre sú tu!",
+        "Learn more": "Naučiť sa viac",
+        "Linking Widgets": "Prepojenie Miniaplikácií",
+        "The total size of all data in this document, excluding attachments.": "Celková veľkosť všetkých údajov v tomto dokumente okrem príloh."
+    },
+    "ColumnTitle": {
+        "Add description": "Pridať popis",
+        "COLUMN ID: ": "ID STĹPCA: ",
+        "Column label": "Označenie stĺpca",
+        "Provide a column label": "Poskytnúť označenie stĺpca",
+        "Cancel": "Zrušiť",
+        "Column ID copied to clipboard": "ID stĺpca bolo skopírované do schránky",
+        "Column description": "Popis stĺpca",
+        "Save": "Uložiť",
+        "Close": "Zavrieť"
+    },
+    "PagePanels": {
+        "Open Creator Panel": "Otvoriť Panel Tvorcu",
+        "Close Creator Panel": "Zatvoriť Panel Tvorcu"
+    },
+    "FieldContextMenu": {
+        "Cut": "Vystrihnúť",
+        "Hide field": "Skryť pole",
+        "Paste": "Prilepiť",
+        "Clear field": "Vyčistiť pole",
+        "Copy": "Kopírovať",
+        "Copy anchor link": "Kopírovať odkaz na kotvu"
+    },
+    "WebhookPage": {
+        "Clear Queue": "Vymazať Front",
+        "Webhook Settings": "Nastavenie Webhook",
+        "Removed webhook.": "Webhook bol odstránený.",
+        "Sorry, not all fields can be edited.": "Ľutujeme, nie všetky polia je možné upraviť.",
+        "Status": "Postavenie",
+        "Columns to check when update (separated by ;)": "Stĺpce na kontrolu pri aktualizácii (oddelené ;)",
+        "Cleared webhook queue.": "Vymazaný front webhook.",
+        "Event Types": "Typy Udalostí",
+        "Memo": "Memo",
+        "Name": "Meno",
+        "Enabled": "Povolené",
+        "Ready Column": "Pripravený Stĺpec",
+        "URL": "URL",
+        "Webhook Id": "Webhook ID",
+        "Table": "Tabuľka",
+        "Filter for changes in these columns (semicolon-separated ids)": "Filtrovať zmeny v týchto stĺpcoch (identifikátory oddelené bodkočiarkou)"
+    },
+    "FormulaAssistant": {
+        "Capabilities": "Schopnosti",
+        "Community": "Spoločenstvo",
+        "Preview": "Náhľad",
+        "Need help? Our AI assistant can help.": "Potrebujete pomoc? Náš asistent UI vám môže pomôcť.",
+        "New Chat": "Nový Rozhovor",
+        "Tips": "Tipy",
+        "AI Assistant": "UI Asistent",
+        "Apply": "Použiť",
+        "For higher limits, {{upgradeNudge}}.": "Pre vyššie limity, {{upgradeNudge}}.",
+        "You have used all available credits.": "Využili ste všetky dostupné kredity.",
+        "You have {{numCredits}} remaining credits.": "Zostávajúce kredity máte na {{numCredits}}.",
+        "See our {{helpFunction}} and {{formulaCheat}}, or visit our {{community}} for more help.": "Pozrite si naše {{helpFunction}} a {{formulaCheat}}, alebo navštívte našu {{community}}, kde získate ďalšiu pomoc.",
+        "I can only help with formulas. I cannot build tables, columns, and views, or write access rules.": "Pomôžem len vzorcom. Nemôžem vytvárať tabuľky, stĺpce a zobrazenia ani pravidlá prístupu na zápis.",
+        "Sign Up for Free": "Prihláste sa Zdarma",
+        "Sign up for a free Grist account to start using the Formula AI Assistant.": "Prihláste sa na bezplatný účet Grist a začnite používať UI asistenta Vzorcov.",
+        "Ask the bot.": "Opýtajte sa robota.",
+        "Data": "Údaje",
+        "Formula Help. ": "Pomocník Vzorca. ",
+        "Function List": "Zoznam Funkcií",
+        "Grist's AI Assistance": "Grist's UI Asistent",
+        "Grist's AI Formula Assistance. ": "Grist UI Asistent Vzorcov ",
+        "Formula Cheat Sheet": "Ťahák Vzorca",
+        "Regenerate": "Regenerovať",
+        "Save": "Uložiť",
+        "Cancel": "Zrušiť",
+        "Clear Conversation": "Vymazať Konverzáciu",
+        "Code View": "Zobrazenie Kódu",
+        "Hi, I'm the Grist Formula AI Assistant.": "Ahoj, som Grist UI asistent Vzorcov.",
+        "Learn more": "Naučiť sa viac",
+        "Press Enter to apply suggested formula.": "Stlačením klávesy Enter použijete navrhovaný vzorec.",
+        "There are some things you should know when working with me:": "Pri spolupráci so mnou by ste mali vedieť niekoľko vecí:",
+        "What do you need help with?": "S čím potrebujete pomôcť?",
+        "Formula AI Assistant is only available for logged in users.": "UI Asistent Vzorcov je k dispozícii len pre prihlásených užívateľov.",
+        "For higher limits, contact the site owner.": "Pre vyššie limity kontaktujte vlastníka stránky.",
+        "upgrade to the Pro Team plan": "povýsiť na plán Pro Team",
+        "upgrade your plan": "povýšiť svoj plán"
+    },
+    "WelcomeSitePicker": {
+        "You have access to the following Grist sites.": "Máte prístup k nasledovným stránkam Grist.",
+        "Welcome back": "Vitajte späť",
+        "You can always switch sites using the account menu.": "Stránky môžete vždy prepínať pomocou ponuky konta."
+    },
+    "DescriptionTextArea": {
+        "DESCRIPTION": "POPIS"
+    },
+    "UserManager": {
+        "Guest": "Hosť",
+        "Grist support": "Podpora Grist",
+        "On": "Zapnuté",
+        "Open Access Rules": "Otvoriť Pravidlá Prístupu",
+        "Once you have removed your own access, you will not be able to get it back without assistance from someone else with sufficient access to the {{resourceType}}.": "Keď odstránite svoj vlastný prístup, nebudete ho môcť získať späť bez pomoci niekoho iného s dostatočným prístupom k {{resourceType}}.",
+        "Allow anyone with the link to open.": "Povoliť otvorenie komukoľvek s odkazom.",
+        "Add {{member}} to your team": "Pridať {{member}} do svojho tímu",
+        "Anyone with link ": "Ktokoľvek s odkazom ",
+        "Cancel": "Zrušiť",
+        "Close": "Zavrieť",
+        "Collaborator": "Spolupracovník",
+        "Confirm": "Potvrdiť",
+        "Create a team to share with more people": "Vytvorte tím na zdieľanie s viacerými ľuďmi",
+        "Manage members of team site": "Spravovať členov tímovej lokality",
+        "Copy Link": "Kopírovať Link",
+        "Invite multiple": "Pozvať viacerých",
+        "Invite people to {{resourceType}}": "Pozvite ľudí do {{resourceType}}",
+        "Link copied to clipboard": "Odkaz bol skopírovaný do schránky",
+        "No default access allows access to be         granted to individual documents or workspaces, rather than the full team site.": "Žiadny predvolený prístup neumožňuje udeliť prístup k jednotlivým dokumentom alebo pracovným priestorom, bez prístupu k celej tímovej lokalite.",
+        "Off": "Vypnuté",
+        "Once you have removed your own access,             you will not be able to get it back without assistance              from someone else with sufficient access to the {{name}}.": "Keď odstránite svoj vlastný prístup, nebudete ho môcť získať späť bez pomoci niekoho iného s dostatočným prístupom k {{name}}.",
+        "Outside collaborator": "Externý spolupracovník",
+        "Your role for this team site": "Vaša rola pre túto tímovú lokalitu",
+        "Your role for this {{resourceType}}": "Vaša rola pre tento {{resourceType}}",
+        "free collaborator": "slobodný spolupracovník",
+        "guest": "hosť",
+        "member": "člen",
+        "team site": "tímová stránka",
+        "{{collaborator}} limit exceeded": "{{collaborator}} prekročený limit",
+        "{{limitAt}} of {{limitTop}} {{collaborator}}s": "{{limitAt}} z {{limitTop}} {{collaborator}}",
+        "No default access allows access to be granted to individual documents or workspaces, rather than the full team site.": "Žiadny predvolený prístup neumožňuje udeliť prístup k jednotlivým dokumentom alebo pracovným priestorom, bez prístupu k celej tímovej lokalite.",
+        "User may not modify their own access.": "Používateľ nesmie meniť svoj vlastný prístup.",
+        "Public Access": "Verejný Prístup",
+        "Public access": "Verejný prístup",
+        "Public access inherited from {{parent}}. To remove, set 'Inherit access' option to 'None'.": "Verejný prístup zdedený po {{parent}}. Ak ho chcete odstrániť, nastavte možnosť „Zdediť prístup“ na „Žiadny“.",
+        "Public access: ": "Verejný prístup: ",
+        "Remove my access": "Odstrániť môj prístup",
+        "Save & ": "Uložiť & ",
+        "Team member": "Člen tímu",
+        "User inherits permissions from {{parent})}. To remove,           set 'Inherit access' option to 'None'.": "Používateľ zdedí povolenia od {{parent})}. Ak to chcete zmeniť, nastavte možnosť „Zdediť prístup“ na „Žiadny“.",
+        "User has view access to {{resource}} resulting from manually-set access to resources inside. If removed here, this user will lose access to resources inside.": "Používateľ má prístup na zobrazenie zdroja {{resource}} v dôsledku manuálne nastaveného prístupu k vnútorným zdrojom. Ak sa odstráni, používateľ stratí prístup k vnútorným zdrojom.",
+        "User inherits permissions from {{parent}}. To remove, set 'Inherit access' option to 'None'.": "Používateľ zdedí povolenia od {{parent}}. Ak ich chcete odstrániť, nastavte možnosť „Zdediť prístup“ na „Žiadny“.",
+        "You are about to remove your own access to this {{resourceType}}": "Chystáte sa odstrániť svoj vlastný prístup k {{resourceType}}"
+    },
+    "SupportGristNudge": {
+        "Admin Panel": "Panel Správcu",
+        "Thank you! Your trust and support is greatly appreciated. Opt out any time from the {{link}} in the user menu.": "Ďakujem! Vašu dôveru a podporu si veľmi vážime. Kedykoľvek sa môžete odhlásiť z {{link}} v používateľskej ponuke.",
+        "Opt in to Telemetry": "Prihláste sa do Telemetrie",
+        "Opted In": "Prihlásené",
+        "Support Grist": "Podpora Grist",
+        "Support Grist page": "Stránka Podpory Grist",
+        "Help Center": "Centrum Pomoci",
+        "Close": "Zavrieť",
+        "Contribute": "Prispieť"
+    },
+    "SupportGristPage": {
+        "This instance is opted in to telemetry. Only the site administrator has permission to change this.": "Táto inštancia je aktivovaná pre telemetriu. Toto môže zmeniť iba správca stránky.",
+        "Sponsor": "Sponzor",
+        "Help Center": "Centrum Pomoci",
+        "Manage Sponsorship": "Spravovať Sponzorovanie",
+        "Telemetry": "Telemetria",
+        "This instance is opted out of telemetry. Only the site administrator has permission to change this.": "Táto inštancia je odhlásená z telemetrie. Toto môže zmeniť iba správca stránky.",
+        "GitHub": "GitHub",
+        "GitHub Sponsors page": "GitHub Stránka sponzorov",
+        "Home": "Domov",
+        "Opt in to Telemetry": "Prihláste sa do Telemetrie",
+        "Opt out of Telemetry": "Odhlásiť sa z Telemetrie",
+        "Sponsor Grist Labs on GitHub": "Sponzorujte Grist Labs na GitHub",
+        "Support Grist": "Podpora Grist",
+        "You can opt out of telemetry at any time from this page.": "Na tejto stránke sa môžete kedykoľvek odhlásiť z telemetrie.",
+        "We only collect usage statistics, as detailed in our {{link}}, never document contents.": "Zhromažďujeme iba štatistiky používania, ako je podrobne uvedené v našom {{link}}, nikdy nedokumentujeme obsah.",
+        "You have opted in to telemetry. Thank you!": "Prihlásili ste sa do telemetrie. Ďakujeme!",
+        "You have opted out of telemetry.": "Deaktivovali ste telemetriu."
+    },
+    "FormView": {
+        "Anyone with the link below can see the empty form and submit a response.": "Každý, kto má nižšie uvedený odkaz, môže vidieť prázdny formulár a odoslať odpoveď.",
+        "Are you sure you want to reset your form?": "Naozaj chcete resetovať svoj formulár?",
+        "View": "Zobrazenie",
+        "Copy code": "Kopírovať kód",
+        "Embed this form": "Vložiť tento formulár",
+        "Reset form": "Resetovať formulár",
+        "Preview": "Náhľad",
+        "Reset": "Resetovať",
+        "Save your document to publish this form.": "Ak chcete zverejniť tento formulár, uložte dokument.",
+        "Unpublish your form?": "Chcete zrušiť zverejnenie formuláru?",
+        "Share this form": "Zdieľať tento formulár",
+        "Publish": "Publikovať",
+        "Publish your form?": "Zverejniť svoj formulár?",
+        "Unpublish": "Nepublikovať",
+        "Code copied to clipboard": "Kód bol skopírovaný do schránky",
+        "Copy link": "Skopírovať odkaz",
+        "Link copied to clipboard": "Odkaz bol skopírovaný do schránky",
+        "Share": "Zdieľať"
+    },
+    "AdminPanel": {
+        "Current version of Grist": "Aktuálna verzia Grist",
+        "Help us make Grist better": "Pomôžte nám zlepšiť Grist",
+        "Home": "Domov",
+        "Error checking for updates": "Kontrola chýb pri aktualizáciách",
+        "Grist is up to date": "Grist je aktuálny",
+        "Current authentication method": "Aktuálna metóda overovania",
+        "Details": "Podrobnosti",
+        "Grist allows different types of authentication to be configured, including SAML and OIDC.     We recommend enabling one of these if Grist is accessible over the network or being made available     to multiple people.": "Grist umožňuje konfigurovať rôzne typy autentifikácie vrátane SAML a OIDC. Odporúčame povoliť jednu z týchto možností, ak je Grist prístupný cez sieť alebo je sprístupnený viacerým ľuďom.",
+        "No fault detected.": "Nebola zistená žiadna porucha.",
+        "Authentication": "Overenie",
+        "Check failed.": "Kontrola zlyhala.",
+        "Check succeeded.": "Kontrola sa podarila.",
+        "Notes": "Poznámky",
+        "Self Checks": "Sebakontroly",
+        "Grist signs user session cookies with a secret key. Please set this key via the environment variable GRIST_SESSION_SECRET. Grist falls back to a hard-coded default when it is not set. We may remove this notice in the future since session IDs have been updated to be inherently cryptographically secure.": "Grist podpisuje súbory cookie relácie používateľa tajným kľúčom. Nastavte tento kľúč prostredníctvom premennej prostredia GRIST_SESSION_SECRET. Ak nie je nastavené, Grist sa vráti späť na pevne zakódované predvolené nastavenie. Toto upozornenie môžeme v budúcnosti odstrániť, pretože ID relácie generované od verzie 1.1.16 sú vo svojej podstate kryptograficky bezpečné.",
+        "Or, as a fallback, you can set: {{bootKey}} in the environment and visit: {{url}}": "Alebo môžete ako náhradnú možnosť nastaviť: {{bootKey}} vo voľbách prostredia a navštíviť ho: {{url}}",
+        "Grist allows different types of authentication to be configured, including SAML and OIDC. We recommend enabling one of these if Grist is accessible over the network or being made available to multiple people.": "Grist umožňuje konfigurovať rôzne typy autentifikácie vrátane SAML a OIDC. Odporúčame povoliť jednu z týchto možností, ak je Grist prístupný cez sieť alebo je sprístupnený viacerým ľuďom.",
+        "Admin Panel": "Panel Správcu",
+        "Current": "Aktuálne",
+        "Sponsor": "Sponzor",
+        "Support Grist": "Podpora Grist",
+        "Support Grist Labs on GitHub": "Podpora Grist Labs na GitHub",
+        "Telemetry": "Telemetria",
+        "Version": "Verzia",
+        "Auto-check when this page loads": "Automatická kontrola pri načítaní tejto stránky",
+        "Check now": "Skontrolovať teraz",
+        "Checking for updates...": "Kontrola aktualizácií...",
+        "Error": "Chyba",
+        "Grist allows for very powerful formulas, using Python. We recommend setting the environment variable GRIST_SANDBOX_FLAVOR to gvisor if your hardware supports it (most will), to run formulas in each document within a sandbox isolated from other documents and isolated from the network.": "Grist umožňuje veľmi výkonné vzorce pomocou Pythonu. Odporúčame nastaviť premennú prostredia GRIST_SANDBOX_FLAVOR na hodnotu gvisor, ak to váš hardvér podporuje (väčšina bude), aby sa vzorce v každom dokumente spúšťali v priestore izolovanom od ostatných dokumentov a izolovanom od siete.",
+        "Grist releases are at ": "Vydania Grist sú na adrese ",
+        "Last checked {{time}}": "Posledná kontrola {{time}}",
+        "Learn more.": "Naučiť sa viac.",
+        "No information available": "Nie sú dostupné žiadne informácie",
+        "Newer version available": "K dispozícii je nová verzia",
+        "OK": "OK",
+        "Administrator Panel Unavailable": "Panel Správcu je Nedostupný",
+        "Results": "Výsledky",
+        "You do not have access to the administrator panel.\nPlease log in as an administrator.": "Nemáte prístup k panelu spávcu.\nPrihláste sa ako správca.",
+        "Sandboxing": "Sandboxing",
+        "Sandbox settings for data engine": "Nastavenia sandboxu pre dátový stroj",
+        "Security Settings": "Bezpečnostné nastavenia",
+        "Updates": "Aktualizácie",
+        "unconfigured": "nekonfigurované",
+        "unknown": "neznáme"
+    },
+    "TimingPage": {
+        "Table ID": "ID Tabuľky",
+        "Total Time (s)": "Celkový Čas (s)",
+        "Average Time (s)": "Priemerný Čas (s)",
+        "Column ID": "ID Stĺpca",
+        "Formula timer": "Časovač Vzorca",
+        "Number of Calls": "Počet Hovorov",
+        "Max Time (s)": "Maximálny Čas (s)",
+        "Loading timing data. Don't close this tab.": "Načítanie časových údajov. Túto kartu nezatvárajte."
+    },
+    "ChoiceListEditor": {
+        "No choices to select": "Žiadne možnosti na výber",
+        "Error in dropdown condition": "Chyba v rozbaľovacej podmienke",
+        "No choices matching condition": "Žiadne možnosti zodpovedajúce podmienke"
+    },
+    "DropdownConditionConfig": {
+        "Dropdown Condition": "Rozbaľovacia Podmienka",
+        "Set dropdown condition": "Nastaviť rozbaľovaciu podmienku",
+        "Invalid columns: {{colIds}}": "Neplatné stĺpce: {{colIds}}"
+    },
+    "FormRenderer": {
+        "Select...": "Vybrať...",
+        "Submit": "Odoslať",
+        "Reset": "Resetovať",
+        "Search": "Hľadať"
+    },
+    "widgetTypesMap": {
+        "Calendar": "Kalendár",
+        "Card": "Karta",
+        "Card List": "Zoznam Kariet",
+        "Chart": "Graf",
+        "Custom": "Vlastné",
+        "Form": "Formulár",
+        "Table": "Tabuľka"
+    },
+    "ReferenceUtils": {
+        "No choices to select": "Žiadne možnosti na výber",
+        "Error in dropdown condition": "Chyba v rozbaľovacej podmienke",
+        "No choices matching condition": "Žiadne možnosti zodpovedajúce podmienke"
+    },
+    "GridView": {
+        "Click to insert": "Kliknutím vložiť"
+    },
+    "WelcomeCoachingCall": {
+        "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.": "Počas hovoru si nájdeme čas na pochopenie vašich potrieb a prispôsobíme sa vám. Môžeme vám ukázať základy Gristu alebo začať pracovať s vašimi údajmi hneď na zostavení panelov, ktoré potrebujete.",
+        "free coaching call": "bezplatný hovor koučovi",
+        "Maybe Later": "Možno Neskôr",
+        "Schedule Call": "Naplánovať Hovor",
+        "Schedule your {{freeCoachingCall}} with a member of our team.": "Naplánujte si {{freeCoachingCall}} s členom nášho tímu."
+    },
+    "FormConfig": {
+        "Horizontal": "Horizontálne",
+        "Options Alignment": "Možnosti Zarovnania",
+        "Options Sort Order": "Možnosti Zoradiť Poradie",
+        "Field rules": "Pravidlá poľa",
+        "Required field": "Vyžadované pole",
+        "Ascending": "Vzostupne",
+        "Default": "Predvolené",
+        "Field Rules": "Pravidlá Poľa",
+        "Radio": "Rádio",
+        "Select": "Výber",
+        "Vertical": "Vertikálne",
+        "Field Format": "Formát Poľa",
+        "Descending": "Zostupne"
+    },
+    "Editor": {
+        "Delete": "Odstrániť"
+    },
+    "Menu": {
+        "Paragraph": "Odsek",
+        "Building blocks": "Stavebné bloky",
+        "Columns": "Stĺpce",
+        "Copy": "Kopírovať",
+        "Cut": "Vystrihnúť",
+        "Insert question above": "Vložte otázku vyššie",
+        "Insert question below": "Vložiť otázku nižšie",
+        "Paste": "Prilepiť",
+        "Separator": "Oddeľovač",
+        "Unmapped fields": "Nemapované polia",
+        "Header": "Hlavička"
+    },
+    "UnmappedFieldsConfig": {
+        "Unmap fields": "Nemapovať polia",
+        "Map fields": "Mapovať polia",
+        "Mapped": "Zmapované",
+        "Select All": "Vybrať Všetko",
+        "Unmapped": "Nemapované",
+        "Clear": "Vyčistiť"
+    },
+    "CustomView": {
+        "Some required columns aren't mapped": "Niektoré povinné stĺpce nie sú namapované",
+        "To use this widget, please map all non-optional columns from the creator panel on the right.": "Ak chcete použiť túto miniaplikáciu, namapujte všetky nepovinné stĺpce z panela tvorcov vpravo."
+    },
+    "Field": {
+        "No values in show column of referenced table": "V zobrazenom stĺpci referenčnej tabuľky nie sú žiadne hodnoty",
+        "No choices configured": "Nie sú nakonfigurované žiadne voľby"
+    },
+    "Toggle": {
+        "Field Format": "Formát Poľa",
+        "Checkbox": "Začiarkavacie políčko",
+        "Switch": "Prepínač"
+    },
+    "ChoiceEditor": {
+        "No choices to select": "Žiadne možnosti na výber",
+        "Error in dropdown condition": "Chyba v rozbaľovacej podmienke",
+        "No choices matching condition": "Žiadne možnosti zodpovedajúce podmienke"
+    },
+    "FormPage": {
+        "There was an error submitting your form. Please try again.": "Pri odosielaní formulára sa vyskytla chyba. Prosím skúste znova."
+    },
+    "FormModel": {
+        "You don't have access to this form.": "K tomuto formuláru nemáte prístup.",
+        "Oops! The form you're looking for doesn't exist.": "Ojoj! Formulár, ktorý hľadáte, neexistuje.",
+        "Oops! This form is no longer published.": "Ojoj! Tento formulár už nie je zverejnený.",
+        "There was a problem loading the form.": "Pri načítavaní formulára sa vyskytol problém."
+    },
+    "FormSuccessPage": {
+        "Form Submitted": "Formulár Odoslaný",
+        "Thank you! Your response has been recorded.": "Ďakujem! Vaša odpoveď bola zaznamenaná.",
+        "Submit new response": "Odoslať novú odpoveď"
+    },
+    "DateRangeOptions": {
+        "This week": "Tento týždeň",
+        "Last 30 days": "Posledných 30 dní",
+        "Last Week": "Minulý Týždeň",
+        "Next 7 days": "Ďalších 7 dní",
+        "This month": "Tento mesiac",
+        "Last 7 days": "Posledných 7 dní",
+        "This year": "Tento rok",
+        "Today": "Dnes"
+    },
+    "Section": {
+        "Insert section below": "Vložiť časť nižšie",
+        "Insert section above": "Vložiť časť vyššie"
+    },
+    "DropdownConditionEditor": {
+        "Enter condition.": "Zadajte podmienku."
+    },
+    "OnBoardingPopups": {
+        "Finish": "Dokončiť",
+        "Previous": "Predchádzajúce",
+        "Next": "Nasledujúce"
+    },
+    "breadcrumbs": {
+        "You may make edits, but they will create a new copy and will\nnot affect the original document.": "Môžete vykonať úpravy, ktoré vytvoria novú kópiu\na neovplyvnia pôvodný dokument.",
+        "unsaved": "neuložené",
+        "fiddle": "husle",
+        "override": "prepísať",
+        "recovery mode": "režim obnovenia",
+        "snapshot": "snímka"
+    },
+    "pages": {
+        "Duplicate Page": "Duplikovať stránku",
+        "Remove": "Odobrať",
+        "Rename": "Premenovať",
+        "You do not have edit access to this document": "Nemáte prístup k úpravám tohto dokumentu"
+    },
+    "SelectionSummary": {
+        "Copied to clipboard": "Skopírované do schránky"
+    },
+    "SiteSwitcher": {
+        "Create new team site": "Vytvoriť novú tímovú lokalitu",
+        "Switch Sites": "Prepnúť lokality"
+    },
+    "SortConfig": {
+        "Add Column": "Pridať Stĺpec",
+        "Empty values last": "Prázdne hodnoty zostávajú",
+        "Natural sort": "Prirodzené triedenie",
+        "Update Data": "Aktualizovať Údaje",
+        "Search Columns": "Hľadať stĺpce",
+        "Use choice position": "Použiť výber pozície"
+    },
+    "SortFilterConfig": {
+        "Filter": "FILTER",
+        "Revert": "Návrat",
+        "Save": "Uložiť",
+        "Sort": "TRIEDIŤ",
+        "Update Sort & Filter settings": "Aktualizovať nastavenie triedenia a filtrovania"
+    },
+    "ThemeConfig": {
+        "Appearance ": "Vzhľad ",
+        "Switch appearance automatically to match system": "Automaticky prepnúť vzhľad tak, aby zodpovedal systému"
+    },
+    "ValidationPanel": {
+        "Rule {{length}}": "Pravidlo {{length}}",
+        "Update formula (Shift+Enter)": "Aktualizovať vzorec (Shift+Enter)"
+    },
+    "VisibleFieldsConfig": {
+        "Cannot drop items into Hidden Fields": "Položky nie je možné umiestniť do Skrytých Polí",
+        "Clear": "Vyčistiť",
+        "Hidden Fields cannot be reordered": "Poradie Skrytých Polí nie je možné zmeniť",
+        "Select All": "Vybrať Všetko",
+        "Visible {{label}}": "Viditeľné {{label}}",
+        "Hide {{label}}": "Skryť {{label}}",
+        "Hidden {{label}}": "Skryté {{label}}",
+        "Show {{label}}": "Zobraziť {{label}}"
+    },
+    "search": {
+        "No results": "Žiadne výsledky",
+        "Search in document": "Hľadať v dokumente",
+        "Search": "Hľadať",
+        "Find Next ": "Nájsť Ďaľší ",
+        "Find Previous ": "Nájsť Predošlý "
+    },
+    "sendToDrive": {
+        "Sending file to Google Drive": "Odoslať súbor na Disk Google"
+    },
+    "ACLUsers": {
+        "Example Users": "Príklady Používateľov",
+        "Users from table": "Používatelia z tabuľky",
+        "View As": "Zobraziť ako"
+    },
+    "CellStyle": {
+        "CELL STYLE": "ŠTÝL BUNKY",
+        "Cell Style": "Štýl Bunky",
+        "Default cell style": "Predvolený štýl bunky",
+        "Mixed style": "Zmiešaný štýl",
+        "HEADER STYLE": "ŠTÝL HLAVYČKY",
+        "Open row styles": "Otvoriť štýly riadkov",
+        "Default header style": "Predvolený štýl hlavičky",
+        "Header Style": "Štýl Hlavičky"
+    },
+    "ChoiceTextBox": {
+        "CHOICES": "VOĽBY"
+    },
+    "ColumnEditor": {
+        "COLUMN DESCRIPTION": "POPIS STĹPCA",
+        "COLUMN LABEL": "OZNAČENIE STĹPCA"
+    },
+    "CurrencyPicker": {
+        "Invalid currency": "Neplatná mena"
+    },
+    "FormulaEditor": {
+        "Column or field is required": "Stĺpec alebo pole je vyžadované",
+        "Error in the cell": "Chyba v bunke",
+        "Errors in {{numErrors}} of {{numCells}} cells": "Chyby v {{numErrors}} z {{numCells}} buniek",
+        "Enter formula or {{button}}.": "Zadajte vzorec alebo {{button}}.",
+        "Expand Editor": "Rozbaliť Editor",
+        "use AI Assistant": "použite UI Asistenta",
+        "editingFormula is required": "Vyžaduje sa úprava Vzorca",
+        "Errors in all {{numErrors}} cells": "Chyby vo všetkých {{numErrors}} bunkách",
+        "Enter formula.": "Zadajte vzorec."
+    },
+    "HyperLinkEditor": {
+        "[link label] url": "[link label] URL"
+    },
+    "DescriptionConfig": {
+        "DESCRIPTION": "POPIS"
+    },
+    "Clipboard": {
+        "Got it": "Mám to",
+        "Unavailable Command": "Nedostupný Príkaz"
+    },
+    "buildViewSectionDom": {
+        "No data": "Žiadne údaje",
+        "No row selected in {{title}}": "V {{title}} nie je vybratý žiadny riadok",
+        "Not all data is shown": "Nezobrazujú sa všetky údaje"
+    },
+    "FloatingEditor": {
+        "Collapse Editor": "Zbaliť Editor"
+    },
+    "FloatingPopup": {
+        "Maximize": "Maximalizovať",
+        "Minimize": "Minimalizovať"
+    },
+    "CardContextMenu": {
+        "Copy anchor link": "Kopírovať odkaz na kotvu",
+        "Delete card": "Odstrániť kartu",
+        "Duplicate card": "Duplikát karty",
+        "Insert card": "Vložiť kartu",
+        "Insert card above": "Vložiť kartu vyššie",
+        "Insert card below": "Vložiť kartu nižšie"
+    },
+    "HiddenQuestionConfig": {
+        "Hidden fields": "Skryté polia"
+    },
+    "FormContainer": {
+        "Build your own form": "Vytvorte si vlastný formulár",
+        "Powered by": "Poháňaný"
+    },
+    "FormErrorPage": {
+        "Error": "Chyba"
+    },
+    "MappedFieldsConfig": {
+        "Clear": "Vyčistiť",
+        "Map fields": "Mapovať polia",
+        "Mapped": "Zmapované",
+        "Select All": "Vybrať Všetko",
+        "Unmap fields": "Nemapovať polia",
+        "Unmapped": "Nemapované"
+    },
+    "CreateTeamModal": {
+        "Cancel": "Zrušiť",
+        "Domain name is required": "Vyžaduje sa názov domény",
+        "Go to your site": "Prejdite na svoju stránku",
+        "Team name": "Názov tímu",
+        "Team name is required": "Vyžaduje sa názov tímu",
+        "Team site created": "Vytvorená tímová stránka",
+        "Team url": "Adresa url tímu",
+        "Work as a Team": "Pracujte ako Tím",
+        "Billing is not supported in grist-core": "Fakturácia nie je podporovaná v grist-core",
+        "Choose a name and url for your team site": "Vyberte si názov a adresu url pre svoju tímovú stránku",
+        "Create site": "Vytvoriť stránku",
+        "Domain name is invalid": "Názov domény je neplatný"
+    },
+    "ViewAsBanner": {
+        "UnknownUser": "Neznámy Používateľ"
+    },
+    "EditorTooltip": {
+        "Convert column to formula": "Previesť stĺpec na vzorec"
+    },
+    "LanguageMenu": {
+        "Language": "Jazyk"
+    },
+    "SearchModel": {
+        "Search all pages": "Prehľadať všetky stránky",
+        "Search all tables": "Prehľadať všetky tabuľky"
+    },
+    "searchDropdown": {
+        "Search": "Vyhľadať"
+    },
+    "Columns": {
+        "Remove Column": "Odobrať stĺpec"
     }
 }

From 3769c5791551da4ec4eda906492455bc106ddf11 Mon Sep 17 00:00:00 2001
From: Paul Janzen <pj@paulgjanzen.com>
Date: Sat, 29 Jun 2024 19:53:47 +0000
Subject: [PATCH 020/145] Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (1340 of 1340 strings)

Translation: Grist/client
Translate-URL: https://hosted.weblate.org/projects/grist/client/pt_BR/
---
 static/locales/pt_BR.client.json | 6 +++++-
 1 file changed, 5 insertions(+), 1 deletion(-)

diff --git a/static/locales/pt_BR.client.json b/static/locales/pt_BR.client.json
index 06457ef5..74685c75 100644
--- a/static/locales/pt_BR.client.json
+++ b/static/locales/pt_BR.client.json
@@ -1617,7 +1617,11 @@
         "Check failed.": "A verificação falhou.",
         "Current authentication method": "Método de autenticação atual",
         "Grist allows different types of authentication to be configured, including SAML and OIDC.     We recommend enabling one of these if Grist is accessible over the network or being made available     to multiple people.": "O Grist permite a configuração de diferentes tipos de autenticação, incluindo SAML e OIDC.     Recomendamos ativar um desses tipos se o Grist for acessível pela rede ou estiver sendo disponibilizado para várias pessoas.",
-        "Grist allows different types of authentication to be configured, including SAML and OIDC. We recommend enabling one of these if Grist is accessible over the network or being made available to multiple people.": "O Grist permite a configuração de diferentes tipos de autenticação, incluindo SAML e OIDC. Recomendamos ativar um desses tipos se o Grist for acessível pela rede ou estiver sendo disponibilizado para várias pessoas."
+        "Grist allows different types of authentication to be configured, including SAML and OIDC. We recommend enabling one of these if Grist is accessible over the network or being made available to multiple people.": "O Grist permite a configuração de diferentes tipos de autenticação, incluindo SAML e OIDC. Recomendamos ativar um desses tipos se o Grist for acessível pela rede ou estiver sendo disponibilizado para várias pessoas.",
+        "Key to sign sessions with": "Chave para assinar sessões com",
+        "Session Secret": "Segredo da sessão",
+        "Grist signs user session cookies with a secret key. Please set this key via the environment variable GRIST_SESSION_SECRET. Grist falls back to a hard-coded default when it is not set. We may remove this notice in the future since session IDs have been updated to be inherently cryptographically secure.": "O Grist assina os cookies de sessão do usuário com uma chave secreta. Defina essa chave por meio da variável de ambiente GRIST_SESSION_SECRET. O Grist retorna a um padrão codificado quando ele não está definido. Poderemos remover esse aviso no futuro, pois os IDs de sessão gerados desde a versão 1.1.16 são inerentemente seguros em termos de criptografia.",
+        "Grist signs user session cookies with a secret key. Please set this key via the environment variable GRIST_SESSION_SECRET. Grist falls back to a hard-coded default when it is not set. We may remove this notice in the future as session IDs generated since v1.1.16 are inherently cryptographically secure.": "O Grist assina os cookies de sessão do usuário com uma chave secreta. Defina essa chave por meio da variável de ambiente GRIST_SESSION_SECRET. O Grist retorna a um padrão codificado quando ele não está definido. Poderemos remover esse aviso no futuro, pois os IDs de sessão gerados desde a versão 1.1.16 são inerentemente seguros em termos de criptografia."
     },
     "Field": {
         "No choices configured": "Nenhuma opção configurada",

From d6d9d1c52e63aa04571c6d3eab4a7d7c50f05772 Mon Sep 17 00:00:00 2001
From: Paul Janzen <pj@paulgjanzen.com>
Date: Sat, 29 Jun 2024 19:54:59 +0000
Subject: [PATCH 021/145] Translated using Weblate (Spanish)

Currently translated at 100.0% (1340 of 1340 strings)

Translation: Grist/client
Translate-URL: https://hosted.weblate.org/projects/grist/client/es/
---
 static/locales/es.client.json | 6 +++++-
 1 file changed, 5 insertions(+), 1 deletion(-)

diff --git a/static/locales/es.client.json b/static/locales/es.client.json
index 9a54b025..b52afd76 100644
--- a/static/locales/es.client.json
+++ b/static/locales/es.client.json
@@ -1607,7 +1607,11 @@
         "Or, as a fallback, you can set: {{bootKey}} in the environment and visit: {{url}}": "O, como alternativa, puedes configurar: {{bootKey}} en el entorno y visita: {{url}}",
         "You do not have access to the administrator panel.\nPlease log in as an administrator.": "No tienes acceso al panel de administrador.\nInicia sesión como administrador.",
         "Self Checks": "Controles automáticos",
-        "Grist allows different types of authentication to be configured, including SAML and OIDC. We recommend enabling one of these if Grist is accessible over the network or being made available to multiple people.": "Grist permite configurar diferentes tipos de autenticación, incluidos SAML y OIDC. Recomendamos habilitar uno de estos si se puede acceder a Grist a través de la red o si está disponible para varias personas."
+        "Grist allows different types of authentication to be configured, including SAML and OIDC. We recommend enabling one of these if Grist is accessible over the network or being made available to multiple people.": "Grist permite configurar diferentes tipos de autenticación, incluidos SAML y OIDC. Recomendamos habilitar uno de estos si se puede acceder a Grist a través de la red o si está disponible para varias personas.",
+        "Session Secret": "Secreto de sesión",
+        "Grist signs user session cookies with a secret key. Please set this key via the environment variable GRIST_SESSION_SECRET. Grist falls back to a hard-coded default when it is not set. We may remove this notice in the future since session IDs have been updated to be inherently cryptographically secure.": "Grist firma las cookies de sesión de usuario con una clave secreta. Establezca esta clave mediante la variable de entorno GRIST_SESSION_SECRET. Si no se establece, Grist vuelve a un valor predeterminado. Es posible que quitemos este aviso en el futuro, ya que los identificadores de sesión generados desde la versión 1.1.16 son intrínsecamente seguros desde el punto de vista criptográfico.",
+        "Key to sign sessions with": "Clave para firmar sesiones con",
+        "Grist signs user session cookies with a secret key. Please set this key via the environment variable GRIST_SESSION_SECRET. Grist falls back to a hard-coded default when it is not set. We may remove this notice in the future as session IDs generated since v1.1.16 are inherently cryptographically secure.": "Grist firma las cookies de sesión de usuario con una clave secreta. Establezca esta clave mediante la variable de entorno GRIST_SESSION_SECRET. Si no se establece, Grist vuelve a un valor predeterminado. Es posible que quitemos este aviso en el futuro, ya que los identificadores de sesión generados desde la versión 1.1.16 son intrínsecamente seguros desde el punto de vista criptográfico."
     },
     "CreateTeamModal": {
         "Cancel": "Cancelar",

From 0e777b1fcf3e9d68e2817b8b2247467cf34c724c Mon Sep 17 00:00:00 2001
From: Paul Janzen <pj@paulgjanzen.com>
Date: Sat, 29 Jun 2024 19:55:29 +0000
Subject: [PATCH 022/145] Translated using Weblate (German)

Currently translated at 100.0% (1340 of 1340 strings)

Translation: Grist/client
Translate-URL: https://hosted.weblate.org/projects/grist/client/de/
---
 static/locales/de.client.json | 6 +++++-
 1 file changed, 5 insertions(+), 1 deletion(-)

diff --git a/static/locales/de.client.json b/static/locales/de.client.json
index f7ed1c6d..8bb45bba 100644
--- a/static/locales/de.client.json
+++ b/static/locales/de.client.json
@@ -1613,7 +1613,11 @@
         "No fault detected.": "Kein Fehler erkannt.",
         "Grist allows different types of authentication to be configured, including SAML and OIDC.     We recommend enabling one of these if Grist is accessible over the network or being made available     to multiple people.": "In Grist können verschiedene Arten der Authentifizierung konfiguriert werden, darunter SAML und OIDC.     Wir empfehlen, eine davon zu aktivieren, wenn Grist über das Netzwerk zugänglich ist oder mehreren Personen zur Verfügung gestellt wird.",
         "Or, as a fallback, you can set: {{bootKey}} in the environment and visit: {{url}}": "Als Ausweichmöglichkeit können Sie auch {{bootKey}} in der Umgebung einstellen und {{url}} besuchen",
-        "Grist allows different types of authentication to be configured, including SAML and OIDC. We recommend enabling one of these if Grist is accessible over the network or being made available to multiple people.": "In Grist können verschiedene Arten der Authentifizierung konfiguriert werden, darunter SAML und OIDC. Wir empfehlen, eine davon zu aktivieren, wenn Grist über das Netzwerk zugänglich ist oder mehreren Personen zur Verfügung gestellt wird."
+        "Grist allows different types of authentication to be configured, including SAML and OIDC. We recommend enabling one of these if Grist is accessible over the network or being made available to multiple people.": "In Grist können verschiedene Arten der Authentifizierung konfiguriert werden, darunter SAML und OIDC. Wir empfehlen, eine davon zu aktivieren, wenn Grist über das Netzwerk zugänglich ist oder mehreren Personen zur Verfügung gestellt wird.",
+        "Grist signs user session cookies with a secret key. Please set this key via the environment variable GRIST_SESSION_SECRET. Grist falls back to a hard-coded default when it is not set. We may remove this notice in the future since session IDs have been updated to be inherently cryptographically secure.": "Grist signiert die Sitzungscookies der Benutzer mit einem geheimen Schlüssel. Bitte setzen Sie diesen Schlüssel über die Umgebungsvariable GRIST_SESSION_SECRET. Grist greift auf eine hart kodierte Voreinstellung zurück, wenn sie nicht gesetzt ist. Wir werden diesen Hinweis möglicherweise in Zukunft entfernen, da Sitzungs-IDs, die seit v1.1.16 erzeugt werden, von Natur aus kryptographisch sicher sind.",
+        "Grist signs user session cookies with a secret key. Please set this key via the environment variable GRIST_SESSION_SECRET. Grist falls back to a hard-coded default when it is not set. We may remove this notice in the future as session IDs generated since v1.1.16 are inherently cryptographically secure.": "Grist signiert die Sitzungscookies der Benutzer mit einem geheimen Schlüssel. Bitte setzen Sie diesen Schlüssel über die Umgebungsvariable GRIST_SESSION_SECRET. Grist greift auf eine hart kodierte Voreinstellung zurück, wenn sie nicht gesetzt ist. Wir werden diesen Hinweis möglicherweise in Zukunft entfernen, da Sitzungs-IDs, die seit v1.1.16 erzeugt werden, von Natur aus kryptographisch sicher sind.",
+        "Key to sign sessions with": "Schlüssel zum Anmelden von Sitzungen mit",
+        "Session Secret": "Sitzungsgeheimnis"
     },
     "Section": {
         "Insert section above": "Abschnitt oben einfügen",

From 61421e82510f924010e356ab6b36a8b7bad3afa7 Mon Sep 17 00:00:00 2001
From: CamilleLegeron <camille@telescoop.fr>
Date: Mon, 1 Jul 2024 15:13:39 +0200
Subject: [PATCH 023/145] Create user last connection datetime (#935)

Each time the a Grist page is reload the `last_connection_at` of the user is updated

resolve [#924](https://github.com/gristlabs/grist-core/issues/924)
---
 app/gen-server/entity/User.ts                 |  3 ++
 app/gen-server/lib/homedb/UsersManager.ts     | 27 +++++++++++-----
 .../migration/1663851423064-UserUUID.ts       | 17 +++++++---
 .../migration/1664528376930-UserRefUnique.ts  | 21 ++++++++----
 .../1713186031023-UserLastConnection.ts       | 18 +++++++++++
 app/server/lib/requestUtils.ts                |  4 +--
 test/gen-server/migrations.ts                 | 32 ++++++++++++++++++-
 7 files changed, 101 insertions(+), 21 deletions(-)
 create mode 100644 app/gen-server/migration/1713186031023-UserLastConnection.ts

diff --git a/app/gen-server/entity/User.ts b/app/gen-server/entity/User.ts
index 2ed10169..c93837cb 100644
--- a/app/gen-server/entity/User.ts
+++ b/app/gen-server/entity/User.ts
@@ -29,6 +29,9 @@ export class User extends BaseEntity {
   @Column({name: 'first_login_at', type: Date, nullable: true})
   public firstLoginAt: Date | null;
 
+  @Column({name: 'last_connection_at', type: Date, nullable: true})
+  public lastConnectionAt: Date | null;
+
   @OneToOne(type => Organization, organization => organization.owner)
   public personalOrg: Organization;
 
diff --git a/app/gen-server/lib/homedb/UsersManager.ts b/app/gen-server/lib/homedb/UsersManager.ts
index 8c0a5dca..168665f3 100644
--- a/app/gen-server/lib/homedb/UsersManager.ts
+++ b/app/gen-server/lib/homedb/UsersManager.ts
@@ -395,14 +395,6 @@ export class UsersManager {
         user.name = (profile && (profile.name || email.split('@')[0])) || '';
         needUpdate = true;
       }
-      if (profile && !user.firstLoginAt) {
-        // set first login time to now (remove milliseconds for compatibility with other
-        // timestamps in db set by typeorm, and since second level precision is fine)
-        const nowish = new Date();
-        nowish.setMilliseconds(0);
-        user.firstLoginAt = nowish;
-        needUpdate = true;
-      }
       if (!user.picture && profile && profile.picture) {
         // Set the user's profile picture if our provider knows it.
         user.picture = profile.picture;
@@ -432,6 +424,25 @@ export class UsersManager {
         user.options = {...(user.options ?? {}), authSubject: userOptions.authSubject};
         needUpdate = true;
       }
+
+      // get date of now (remove milliseconds for compatibility with other
+      // timestamps in db set by typeorm, and since second level precision is fine)
+      const nowish = new Date();
+      nowish.setMilliseconds(0);
+      if (profile && !user.firstLoginAt) {
+        // set first login time to now
+        user.firstLoginAt = nowish;
+        needUpdate = true;
+      }
+      const getTimestampStartOfDay = (date: Date) => {
+        const timestamp = Math.floor(date.getTime() / 1000); // unix timestamp seconds from epoc
+        const startOfDay = timestamp - (timestamp % 86400 /*24h*/); // start of a day in seconds since epoc
+        return startOfDay;
+      };
+      if (!user.lastConnectionAt || getTimestampStartOfDay(user.lastConnectionAt) !== getTimestampStartOfDay(nowish)) {
+        user.lastConnectionAt = nowish;
+        needUpdate = true;
+      }
       if (needUpdate) {
         login.user = user;
         await manager.save([user, login]);
diff --git a/app/gen-server/migration/1663851423064-UserUUID.ts b/app/gen-server/migration/1663851423064-UserUUID.ts
index ba0e71b1..60c86668 100644
--- a/app/gen-server/migration/1663851423064-UserUUID.ts
+++ b/app/gen-server/migration/1663851423064-UserUUID.ts
@@ -1,5 +1,5 @@
-import {User} from 'app/gen-server/entity/User';
 import {makeId} from 'app/server/lib/idUtils';
+import {chunk} from 'lodash';
 import {MigrationInterface, QueryRunner, TableColumn} from "typeorm";
 
 export class UserUUID1663851423064 implements MigrationInterface {
@@ -16,11 +16,20 @@ export class UserUUID1663851423064 implements MigrationInterface {
     // Updating so many rows in a multiple queries is not ideal. We will send updates in chunks.
     // 300 seems to be a good number, for 24k rows we have 80 queries.
     const userList = await queryRunner.manager.createQueryBuilder()
-      .select("users")
-      .from(User, "users")
+      .select(["users.id", "users.ref"])
+      .from("users", "users")
       .getMany();
     userList.forEach(u => u.ref = makeId());
-    await queryRunner.manager.save(userList, { chunk: 300 });
+
+    const userChunks = chunk(userList, 300);
+    for (const users of userChunks) {
+      await queryRunner.connection.transaction(async manager => {
+        const queries = users.map((user: any, _index: number, _array: any[]) => {
+          return queryRunner.manager.update("users", user.id, user);
+        });
+        await Promise.all(queries);
+      });
+    }
 
     // We are not making this column unique yet, because it can fail
     // if there are some old workers still running, and any new user
diff --git a/app/gen-server/migration/1664528376930-UserRefUnique.ts b/app/gen-server/migration/1664528376930-UserRefUnique.ts
index 27536042..149be01e 100644
--- a/app/gen-server/migration/1664528376930-UserRefUnique.ts
+++ b/app/gen-server/migration/1664528376930-UserRefUnique.ts
@@ -1,5 +1,5 @@
-import {User} from 'app/gen-server/entity/User';
 import {makeId} from 'app/server/lib/idUtils';
+import {chunk} from 'lodash';
 import {MigrationInterface, QueryRunner} from "typeorm";
 
 export class UserRefUnique1664528376930 implements MigrationInterface {
@@ -9,12 +9,21 @@ export class UserRefUnique1664528376930 implements MigrationInterface {
 
     // Update users that don't have unique ref set.
     const userList = await queryRunner.manager.createQueryBuilder()
-      .select("users")
-      .from(User, "users")
-      .where("ref is null")
-      .getMany();
+    .select(["users.id", "users.ref"])
+    .from("users", "users")
+    .where("users.ref is null")
+    .getMany();
     userList.forEach(u => u.ref = makeId());
-    await queryRunner.manager.save(userList, {chunk: 300});
+
+    const userChunks = chunk(userList, 300);
+    for (const users of userChunks) {
+      await queryRunner.connection.transaction(async manager => {
+        const queries = users.map((user: any, _index: number, _array: any[]) => {
+          return queryRunner.manager.update("users", user.id, user);
+        });
+        await Promise.all(queries);
+      });
+    }
 
     // Mark column as unique and non-nullable.
     const users = (await queryRunner.getTable('users'))!;
diff --git a/app/gen-server/migration/1713186031023-UserLastConnection.ts b/app/gen-server/migration/1713186031023-UserLastConnection.ts
new file mode 100644
index 00000000..52310a38
--- /dev/null
+++ b/app/gen-server/migration/1713186031023-UserLastConnection.ts
@@ -0,0 +1,18 @@
+import {MigrationInterface, QueryRunner, TableColumn} from 'typeorm';
+
+export class UserLastConnection1713186031023 implements MigrationInterface {
+
+  public async up(queryRunner: QueryRunner): Promise<any> {
+    const sqlite = queryRunner.connection.driver.options.type === 'sqlite';
+    const datetime = sqlite ? "datetime" : "timestamp with time zone";
+    await queryRunner.addColumn('users', new TableColumn({
+      name: 'last_connection_at',
+      type: datetime,
+      isNullable: true
+    }));
+  }
+
+  public async down(queryRunner: QueryRunner): Promise<any> {
+    await queryRunner.dropColumn('users', 'last_connection_at');
+  }
+}
diff --git a/app/server/lib/requestUtils.ts b/app/server/lib/requestUtils.ts
index a6f29106..de0326d4 100644
--- a/app/server/lib/requestUtils.ts
+++ b/app/server/lib/requestUtils.ts
@@ -21,8 +21,8 @@ export const TEST_HTTPS_OFFSET = process.env.GRIST_TEST_HTTPS_OFFSET ?
 
 // Database fields that we permit in entities but don't want to cross the api.
 const INTERNAL_FIELDS = new Set([
-  'apiKey', 'billingAccountId', 'firstLoginAt', 'filteredOut', 'ownerId', 'gracePeriodStart', 'stripeCustomerId',
-  'stripeSubscriptionId', 'stripeProductId', 'userId', 'isFirstTimeUser', 'allowGoogleLogin',
+  'apiKey', 'billingAccountId', 'firstLoginAt', 'lastConnectionAt', 'filteredOut', 'ownerId', 'gracePeriodStart',
+  'stripeCustomerId', 'stripeSubscriptionId', 'stripeProductId', 'userId', 'isFirstTimeUser', 'allowGoogleLogin',
   'authSubject', 'usage', 'createdBy'
 ]);
 
diff --git a/test/gen-server/migrations.ts b/test/gen-server/migrations.ts
index 9b3e31e5..e6a45b98 100644
--- a/test/gen-server/migrations.ts
+++ b/test/gen-server/migrations.ts
@@ -42,6 +42,8 @@ import {ActivationPrefs1682636695021 as ActivationPrefs} from 'app/gen-server/mi
 import {AssistantLimit1685343047786 as AssistantLimit} from 'app/gen-server/migration/1685343047786-AssistantLimit';
 import {Shares1701557445716 as Shares} from 'app/gen-server/migration/1701557445716-Shares';
 import {Billing1711557445716 as BillingFeatures} from 'app/gen-server/migration/1711557445716-Billing';
+import {UserLastConnection1713186031023
+        as UserLastConnection} from 'app/gen-server/migration/1713186031023-UserLastConnection';
 
 const home: HomeDBManager = new HomeDBManager();
 
@@ -50,7 +52,8 @@ const migrations = [Initial, Login, PinDocs, UserPicture, DisplayEmail, DisplayE
                     CustomerIndex, ExtraIndexes, OrgHost, DocRemovedAt, Prefs,
                     ExternalBilling, DocOptions, Secret, UserOptions, GracePeriodStart,
                     DocumentUsage, Activations, UserConnectId, UserUUID, UserUniqueRefUUID,
-                    Forks, ForkIndexes, ActivationPrefs, AssistantLimit, Shares, BillingFeatures];
+                    Forks, ForkIndexes, ActivationPrefs, AssistantLimit, Shares, BillingFeatures,
+                    UserLastConnection];
 
 // Assert that the "members" acl rule and group exist (or not).
 function assertMembersGroup(org: Organization, exists: boolean) {
@@ -113,6 +116,33 @@ describe('migrations', function() {
     // be doing something.
   });
 
+  it('can migrate UserUUID and UserUniqueRefUUID with user in table', async function() {
+    this.timeout(60000);
+    const runner = home.connection.createQueryRunner();
+
+    // Create 400 users to test the chunk (each chunk is 300 users)
+    const nbUsersToCreate = 400;
+    for (const migration of migrations) {
+      if (migration === UserUUID) {
+        for (let i = 0; i < nbUsersToCreate; i++) {
+          await runner.query(`INSERT INTO users (id, name, is_first_time_user) VALUES (${i}, 'name${i}', true)`);
+        }
+      }
+
+      await (new migration()).up(runner);
+    }
+
+    // Check that all refs are unique
+    const userList = await runner.manager.createQueryBuilder()
+      .select(["users.id", "users.ref"])
+      .from("users", "users")
+      .getMany();
+    const setOfUserRefs = new Set(userList.map(u => u.ref));
+    assert.equal(nbUsersToCreate, userList.length);
+    assert.equal(setOfUserRefs.size, userList.length);
+    await addSeedData(home.connection);
+  });
+
   it('can correctly switch display_email column to non-null with data', async function() {
     this.timeout(60000);
     const sqlite = home.connection.driver.options.type === 'sqlite';

From 6e11e497bc80591739ed49d2ccc9286bcc2652f9 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= <jordigh@octave.org>
Date: Fri, 28 Jun 2024 20:15:59 -0400
Subject: [PATCH 024/145] workflows: Do not use `ext/` director to run tests

We need this directory for building the image, but not for running the
tests outside of it.
---
 .github/workflows/docker_latest.yml | 8 +++++---
 1 file changed, 5 insertions(+), 3 deletions(-)

diff --git a/.github/workflows/docker_latest.yml b/.github/workflows/docker_latest.yml
index 4a6f2c1c..01abfd84 100644
--- a/.github/workflows/docker_latest.yml
+++ b/.github/workflows/docker_latest.yml
@@ -82,14 +82,16 @@ jobs:
 
       - name: Build Node.js code
         run: |
-          pushd ext && \
-          { if [ -e package.json ] ; then yarn install --frozen-lockfile --modules-folder=../../node_modules; fi } && \
-          popd
+          rm -rf ext
           yarn run build:prod
 
       - name: Run tests
         run: TEST_IMAGE=${{ github.repository_owner }}/${{ matrix.image.name }}:experimental VERBOSE=1 DEBUG=1 MOCHA_WEBDRIVER_HEADLESS=1 yarn run test:docker
 
+      - name: Restore the ext/ directory
+        if: matrix.image.name != 'grist-oss'
+        run: buildtools/checkout-ext-directory.sh ${{ matrix.image.repo }}
+
       - name: Log in to Docker Hub
         uses: docker/login-action@v1 
         with:

From 6888f9bceeb7d96db524be46ff414bf664c7fb6d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= <jordigh@octave.org>
Date: Fri, 28 Jun 2024 20:16:50 -0400
Subject: [PATCH 025/145] tsconfig-ext: revert
 bc52f65b2648ff7987383537fc06c6a388d29ce2

While the intent was to run tests with it, we don't need it. Instead,
this caused problems because the stubs overrode the intended `ext`
directory and therefore disabled the ext features.
---
 tsconfig-ext.json | 3 ---
 1 file changed, 3 deletions(-)

diff --git a/tsconfig-ext.json b/tsconfig-ext.json
index cfa355ab..814d5c36 100644
--- a/tsconfig-ext.json
+++ b/tsconfig-ext.json
@@ -3,9 +3,6 @@
   "files": [],
   "include": [],
   "references": [
-    { "path": "./app" },
-    { "path": "./stubs/app" },
-    { "path": "./test" },
     { "path": "./ext/app" }
   ],
 }

From 6801732c29030540464acc1c44b939fd1aed398f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Gr=C3=A9goire=20Cutzach?= <gregoire@cutzach.com>
Date: Mon, 1 Jul 2024 09:15:53 +0000
Subject: [PATCH 026/145] Translated using Weblate (French)

Currently translated at 99.1% (1329 of 1340 strings)

Translation: Grist/client
Translate-URL: https://hosted.weblate.org/projects/grist/client/fr/
---
 static/locales/fr.client.json | 17 ++++++++++++++---
 1 file changed, 14 insertions(+), 3 deletions(-)

diff --git a/static/locales/fr.client.json b/static/locales/fr.client.json
index 1c64e75c..6f5a9536 100644
--- a/static/locales/fr.client.json
+++ b/static/locales/fr.client.json
@@ -335,7 +335,9 @@
         "Formula timer": "Chronomètre de formule",
         "Timing is on": "Le chronomètre tourne",
         "You can make changes to the document, then stop timing to see the results.": "Vous pouvez apporter des modifications au document, puis arrêter le chronométrage pour voir les résultats.",
-        "Formula times": "Minuteur de formule"
+        "Formula times": "Minuteur de formule",
+        "Only available to document editors": "Seulement disponible aux éditeurs du document",
+        "Only available to document owners": "Seulement disponible aux propriétaires du document"
     },
     "DocumentUsage": {
         "Usage statistics are only available to users with full access to the document data.": "Les statistiques d'utilisation ne sont disponibles qu'aux utilisateurs ayant un accès complet aux données du document.",
@@ -1120,7 +1122,8 @@
         "min": "min",
         "Text": "Texte",
         "max": "max",
-        "Field Format": "Format du champ"
+        "Field Format": "Format du champ",
+        "Spinner": "Roue"
     },
     "LanguageMenu": {
         "Language": "Langue"
@@ -1547,7 +1550,15 @@
         "Details": "Détails",
         "Grist allows different types of authentication to be configured, including SAML and OIDC.     We recommend enabling one of these if Grist is accessible over the network or being made available     to multiple people.": "Grist permet de configurer différents types d'authentification, notamment SAML et OIDC. Nous recommandons d'activer l'un de ces types d'authentification si Grist est accessible via le réseau ou s'il est mis à la disposition de plusieurs personnes.",
         "You do not have access to the administrator panel.\nPlease log in as an administrator.": "Vous n'avez pas accès au panneau d'administrateur.\nVeuillez vous connecter en tant qu'administrateur.",
-        "Results": "Résultats"
+        "Results": "Résultats",
+        "Grist signs user session cookies with a secret key. Please set this key via the environment variable GRIST_SESSION_SECRET. Grist falls back to a hard-coded default when it is not set. We may remove this notice in the future since session IDs have been updated to be inherently cryptographically secure.": "Grist signe les cookies des sessions utilisateurs avec une clé secrète. Merci de renseigner cette clé via la variable d'environnement GRIST_SESSION_SECRET. Grist se replie sur une clé codée en dur par défaut si la variable n'est pas renseignée. La présente remarque sera peut-être retirée dans le futur comme les identifiants de session générés depuis la version 1.1.16 sont cryptographiquement sûrs.",
+        "Key to sign sessions with": "Clé de signature des sessions",
+        "Grist allows different types of authentication to be configured, including SAML and OIDC. We recommend enabling one of these if Grist is accessible over the network or being made available to multiple people.": "Grist autorise la configuration de différents types d'authentifications, parmi lesquels SAML et OIDC. Nous recommandons d'activer l'une d'entre elles si Grist est accessible sur le réseau ou est rendu accessible à plusieurs personnes.",
+        "Grist signs user session cookies with a secret key. Please set this key via the environment variable GRIST_SESSION_SECRET. Grist falls back to a hard-coded default when it is not set. We may remove this notice in the future as session IDs generated since v1.1.16 are inherently cryptographically secure.": "Grist signe les cookies des sessions utilisateurs avec une clé secrète. Merci de renseigner cette clé via la variable d'environnement GRIST_SESSION_SECRET. Grist utilise par défaut une clé codée en dur si la variable n'est pas renseignée. Nous retirerons peut-être cet avertissement à l'avenir comme les identifiants de session générés depuis la version 1.1.16 sont intrinsèquement cryptographiquement sûrs.",
+        "Sandboxing": "Bac à sable",
+        "Self Checks": "Auto contrôles",
+        "Session Secret": "Secret de session",
+        "Or, as a fallback, you can set: {{bootKey}} in the environment and visit: {{url}}": "Ou, comme plan B., vous pouvez renseigner : {{bootKey}} dans l'environnement et visiter : {{url}}"
     },
     "Field": {
         "No choices configured": "Aucun choix configuré",

From 3082fe0f01c79cc2bbb979d1a271a91f0aa5572d Mon Sep 17 00:00:00 2001
From: Roman Holinec <3ko@pixeon.sk>
Date: Mon, 1 Jul 2024 11:39:29 +0000
Subject: [PATCH 027/145] Translated using Weblate (Slovak)

Currently translated at 100.0% (1340 of 1340 strings)

Translation: Grist/client
Translate-URL: https://hosted.weblate.org/projects/grist/client/sk/
---
 static/locales/sk.client.json | 11 +++++++----
 1 file changed, 7 insertions(+), 4 deletions(-)

diff --git a/static/locales/sk.client.json b/static/locales/sk.client.json
index 7bc059b3..8dbd9f36 100644
--- a/static/locales/sk.client.json
+++ b/static/locales/sk.client.json
@@ -245,7 +245,7 @@
         "Permanently Delete \"{{name}}\"?": "Natrvalo Odstrániť „{{name}}“?",
         "Pin Document": "Pripnúť Dokument",
         "Pinned Documents": "Pripnuté Dokumenty",
-        "Remove": "Odstrániť",
+        "Remove": "Odobrať",
         "Rename": "Premenovať",
         "Requires edit permissions": "Vyžaduje povolenia na úpravy",
         "To restore this document, restore the workspace first.": "Ak chcete tento dokument obnoviť, najskôr obnovte pracovný priestor.",
@@ -278,7 +278,7 @@
         "Add Empty Table": "Pridať Prázdnu Tabuľku",
         "Add Page": "Pridať Stránku",
         "Add Widget to Page": "Pridať Miniaplikáciu na Stránku",
-        "Document owners can attempt to recover the document. [{{error}}]": "Vlastníci dokumentu sa môžu pokúsiť dokument obnoviť. [{{chyba}}]",
+        "Document owners can attempt to recover the document. [{{error}}]": "Vlastníci dokumentu sa môžu pokúsiť dokument obnoviť. {{chyba}}",
         "Sorry, access to this document has been denied. [{{error}}]": "Ľutujeme, prístup k tomuto dokumentu bol odmietnutý. [{{error}}]",
         "You can try reloading the document, or using recovery mode. Recovery mode opens the document to be fully accessible to owners, and inaccessible to others. It also disables formulas. [{{error}}]": "Môžete skúsiť znova načítať dokument alebo použiť režim obnovenia. Režim obnovenia otvorí dokument tak, aby bol plne prístupný pre vlastníkov a neprístupný pre ostatných. Zakáže tiež vzorce. [{{error}}]",
         "You do not have edit access to this document": "Nemáte prístup k úpravám tohto dokumentu"
@@ -516,7 +516,7 @@
         "To use Grist, please either sign up or sign in.": "Ak chcete používať Grist, zaregistrujte sa alebo sa prihláste.",
         "Visit our {{link}} to learn more about Grist.": "Navštíviť náš {{link}} a dozvedieť sa viac o Grist.",
         "Learn more in our {{helpCenterLink}}, or find an expert via our {{sproutsProgram}}.": "Viac informácií nájdete v našom {{helpCenterLink}} alebo nájdite odborníka prostredníctvom nášho {{sproutsProgram}}.",
-        "Interested in using Grist outside of your team? Visit your free ": "Máte záujem používať Grist mimo váš tím?  Navštívte svoje bezplatné ",
+        "Interested in using Grist outside of your team? Visit your free ": "Máte záujem používať Grist mimo váš tím? Navštívte svoje bezplatné ",
         "Sign up": "Prihlásiť sa"
     },
     "Importer": {
@@ -1304,7 +1304,10 @@
         "Security Settings": "Bezpečnostné nastavenia",
         "Updates": "Aktualizácie",
         "unconfigured": "nekonfigurované",
-        "unknown": "neznáme"
+        "unknown": "neznáme",
+        "Grist signs user session cookies with a secret key. Please set this key via the environment variable GRIST_SESSION_SECRET. Grist falls back to a hard-coded default when it is not set. We may remove this notice in the future as session IDs generated since v1.1.16 are inherently cryptographically secure.": "Grist podpisuje súbory cookie relácie používateľa tajným kľúčom. Nastavte tento kľúč prostredníctvom premennej prostredia GRIST_SESSION_SECRET. Grist sa vráti späť na pevne zakódované predvolené nastavenie, keď nie je nastavené. Toto upozornenie môžeme v budúcnosti odstrániť, pretože ID relácie generované od verzie 1.1.16 sú vo svojej podstate kryptograficky bezpečné.",
+        "Key to sign sessions with": "Kľúč na podpisovanie relácií",
+        "Session Secret": "Tajomstvo Relácie"
     },
     "TimingPage": {
         "Table ID": "ID Tabuľky",

From 7f28aee79c91343f67c32e19e32e2b2df44b205f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jaros=C5=82aw=20Sadzi=C5=84ski?=
 <jaroslaw.sadzinski@gmail.com>
Date: Thu, 27 Jun 2024 12:39:28 +0200
Subject: [PATCH 028/145] (core) Billing updates

Summary:
- Adding confirmation dialog when user doesn't want to cancel site
- Changing `Cancel subscription` to `Cancel plan`
- Removing `Pro` from upgrade header on pricing modal
- Better handling situation when there is no default price
- Removing mentions about sprouts program
- Removing cache for stripe plans

Test Plan: Updated tests

Reviewers: georgegevoian

Reviewed By: georgegevoian

Differential Revision: https://phab.getgrist.com/D4273
---
 app/client/ui/HomeIntro.ts       | 5 ++---
 app/common/gristUrls.ts          | 1 -
 static/locales/bg.client.json    | 2 +-
 static/locales/de.client.json    | 2 +-
 static/locales/en.client.json    | 2 +-
 static/locales/es.client.json    | 2 +-
 static/locales/fr.client.json    | 2 +-
 static/locales/it.client.json    | 2 +-
 static/locales/pt_BR.client.json | 2 +-
 static/locales/sl.client.json    | 2 +-
 test/nbrowser/HomeIntro.ts       | 2 +-
 11 files changed, 11 insertions(+), 13 deletions(-)

diff --git a/app/client/ui/HomeIntro.ts b/app/client/ui/HomeIntro.ts
index 4c830da7..23ea1cd1 100644
--- a/app/client/ui/HomeIntro.ts
+++ b/app/client/ui/HomeIntro.ts
@@ -83,7 +83,6 @@ function makeViewerTeamSiteIntro(homeModel: HomeModel) {
 }
 
 function makeTeamSiteIntro(homeModel: HomeModel) {
-  const sproutsProgram = cssLink({href: commonUrls.sproutsProgram, target: '_blank'}, t("Sprouts Program"));
   return [
     css.docListHeader(
       t("Welcome to {{- orgName}}", {orgName: homeModel.app.currentOrgName}),
@@ -94,8 +93,8 @@ function makeTeamSiteIntro(homeModel: HomeModel) {
     (!isFeatureEnabled('helpCenter') ? null :
       cssIntroLine(
         t(
-          'Learn more in our {{helpCenterLink}}, or find an expert via our {{sproutsProgram}}.',
-          {helpCenterLink: helpCenterLink(), sproutsProgram}
+          'Learn more in our {{helpCenterLink}}.',
+          {helpCenterLink: helpCenterLink()}
         ),
         testId('welcome-text')
       )
diff --git a/app/common/gristUrls.ts b/app/common/gristUrls.ts
index 8e6eb505..1f66291e 100644
--- a/app/common/gristUrls.ts
+++ b/app/common/gristUrls.ts
@@ -93,7 +93,6 @@ export const commonUrls = {
   contactSupport: getContactSupportUrl(),
   termsOfService: getTermsOfServiceUrl(),
   plans: "https://www.getgrist.com/pricing",
-  sproutsProgram: "https://www.getgrist.com/sprouts-program",
   contact: "https://www.getgrist.com/contact",
   templates: 'https://www.getgrist.com/templates',
   community: 'https://community.getgrist.com',
diff --git a/static/locales/bg.client.json b/static/locales/bg.client.json
index fcfef72e..a5865040 100644
--- a/static/locales/bg.client.json
+++ b/static/locales/bg.client.json
@@ -499,7 +499,7 @@
         "Sign in": "Вписване",
         "To use Grist, please either sign up or sign in.": "За да използвате Grist, моля, регистрирайте се или се впишете.",
         "Visit our {{link}} to learn more about Grist.": "Посетете {{link}}, за да научите повече за Grist.",
-        "Learn more in our {{helpCenterLink}}, or find an expert via our {{sproutsProgram}}.": "Научете повече в нашия {{helpCenterLink}} или намерете експерт чрез нашата {{sproutsProgram}}.",
+        "Learn more in our {{helpCenterLink}}.": "Научете повече в нашия {{helpCenterLink}}.",
         "Get started by creating your first Grist document.": "Започнете, като създадете първия си Grist документ.",
         "Get started by exploring templates, or creating your first Grist document.": "Започнете, като проучите образците или създадете първия си Grist документ.",
         "Invite Team Members": "Поканете членове на екипа",
diff --git a/static/locales/de.client.json b/static/locales/de.client.json
index 8bb45bba..c16e2911 100644
--- a/static/locales/de.client.json
+++ b/static/locales/de.client.json
@@ -555,7 +555,7 @@
         "Visit our {{link}} to learn more about Grist.": "Besuchen Sie unsere {{link}}, um mehr über Grist zu erfahren.",
         "Sign in": "Anmelden",
         "To use Grist, please either sign up or sign in.": "Um Grist zu nutzen, melden Sie sich bitte an oder registrieren Sie sich.",
-        "Learn more in our {{helpCenterLink}}, or find an expert via our {{sproutsProgram}}.": "Erfahren Sie mehr in unserem {{helpCenterLink}}, oder finden Sie einen Experten über unser {{sproutsProgram}}."
+        "Learn more in our {{helpCenterLink}}.": "Erfahren Sie mehr in unserem {{helpCenterLink}}."
     },
     "HomeLeftPane": {
         "Access Details": "Zugangsdetails",
diff --git a/static/locales/en.client.json b/static/locales/en.client.json
index 420e580e..c1df1049 100644
--- a/static/locales/en.client.json
+++ b/static/locales/en.client.json
@@ -523,7 +523,7 @@
         "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.",
-        "Learn more in our {{helpCenterLink}}, or find an expert via our {{sproutsProgram}}.": "Learn more in our {{helpCenterLink}}, or find an expert via our {{sproutsProgram}}."
+        "Learn more in our {{helpCenterLink}}.": "Learn more in our {{helpCenterLink}}."
     },
     "HomeLeftPane": {
         "Access Details": "Access Details",
diff --git a/static/locales/es.client.json b/static/locales/es.client.json
index b52afd76..f59ad29e 100644
--- a/static/locales/es.client.json
+++ b/static/locales/es.client.json
@@ -471,7 +471,7 @@
         "Visit our {{link}} to learn more about Grist.": "Visita nuestra {{link}} para obtener más información sobre Grist.",
         "Sign in": "Iniciar sesión",
         "To use Grist, please either sign up or sign in.": "Para utilizar Grist, regístrate o inicia sesión.",
-        "Learn more in our {{helpCenterLink}}, or find an expert via our {{sproutsProgram}}.": "Obtenga más información en nuestro {{helpCenterLink}}, o busque un experto a través de nuestro {{sproutsProgram}}."
+        "Learn more in our {{helpCenterLink}}.": "Obtenga más información en nuestro {{helpCenterLink}}."
     },
     "HomeLeftPane": {
         "Access Details": "Detalles de Acceso",
diff --git a/static/locales/fr.client.json b/static/locales/fr.client.json
index 6f5a9536..463acae1 100644
--- a/static/locales/fr.client.json
+++ b/static/locales/fr.client.json
@@ -520,7 +520,7 @@
         "Visit our {{link}} to learn more about Grist.": "Visitez notre {{link}} pour en savoir plus sur Grist.",
         "Sign in": "Connexion",
         "To use Grist, please either sign up or sign in.": "Pour utiliser Grist, connectez-vous ou créez-vous un compte.",
-        "Learn more in our {{helpCenterLink}}, or find an expert via our {{sproutsProgram}}.": "Pour en savoir plus, consultez notre {{helpCenterLink}}, ou trouvez un expert via notre {{sproutsProgram}}."
+        "Learn more in our {{helpCenterLink}}.": "Pour en savoir plus, consultez notre {{helpCenterLink}}."
     },
     "HomeLeftPane": {
         "All Documents": "Tous les documents",
diff --git a/static/locales/it.client.json b/static/locales/it.client.json
index bdae0869..4c46628b 100644
--- a/static/locales/it.client.json
+++ b/static/locales/it.client.json
@@ -25,7 +25,7 @@
         "Visit our {{link}} to learn more about Grist.": "Vai a {{link}} per saperne di più su Grist.",
         "Sign in": "Accedi",
         "To use Grist, please either sign up or sign in.": "Per usare Grist, iscriviti o accedi.",
-        "Learn more in our {{helpCenterLink}}, or find an expert via our {{sproutsProgram}}.": "Approfondisci nel nostro {{helpCenterLink}}, o trova un esperto con il nostro {{sproutsProgram}}."
+        "Learn more in our {{helpCenterLink}}.": "Approfondisci nel nostro {{helpCenterLink}}."
     },
     "HomeLeftPane": {
         "Manage Users": "Gestisci gli utenti",
diff --git a/static/locales/pt_BR.client.json b/static/locales/pt_BR.client.json
index 74685c75..c41e9b0e 100644
--- a/static/locales/pt_BR.client.json
+++ b/static/locales/pt_BR.client.json
@@ -555,7 +555,7 @@
         "Visit our {{link}} to learn more about Grist.": "Visite nosso site {{link}} para saber mais sobre o Grist.",
         "Sign in": "Entrar",
         "To use Grist, please either sign up or sign in.": "Para usar o Grist, inscreva-se ou faça login.",
-        "Learn more in our {{helpCenterLink}}, or find an expert via our {{sproutsProgram}}.": "Saiba mais em nosso {{helpCenterLink}}, ou encontre um especialista através do nosso {{sproutsProgram}}."
+        "Learn more in our {{helpCenterLink}}.": "Saiba mais em nosso {{helpCenterLink}}."
     },
     "HomeLeftPane": {
         "Access Details": "Detalhes de Acesso",
diff --git a/static/locales/sl.client.json b/static/locales/sl.client.json
index 7eeb44eb..a2d62da0 100644
--- a/static/locales/sl.client.json
+++ b/static/locales/sl.client.json
@@ -922,7 +922,7 @@
         "Visit our {{link}} to learn more about Grist.": "Obiščite našo spletno stran {{link}} da izveste več o Grisstu.",
         "Sign in": "Prijavi se",
         "To use Grist, please either sign up or sign in.": "Če želiš uporabljati Grist, se prijavi ali prvič prijavi.",
-        "Learn more in our {{helpCenterLink}}, or find an expert via our {{sproutsProgram}}.": "Izvedi več v našem {{helpCenterLink}} ali poišči strokovnjaka prek našega {{sproutsProgram}}."
+        "Learn more in our {{helpCenterLink}}.": "Izvedi več v našem {{helpCenterLink}}."
     },
     "WelcomeSitePicker": {
         "You have access to the following Grist sites.": "Imate dostop do naslednjih Grist spletnih mest .",
diff --git a/test/nbrowser/HomeIntro.ts b/test/nbrowser/HomeIntro.ts
index 3d3b1863..92ee9c58 100644
--- a/test/nbrowser/HomeIntro.ts
+++ b/test/nbrowser/HomeIntro.ts
@@ -87,7 +87,7 @@ describe('HomeIntro', function() {
 
       // Check message specific to logged-in user and an empty team site.
       assert.match(await driver.find('.test-welcome-title').getText(), new RegExp(`Welcome.* ${session.orgName}`));
-      assert.match(await driver.find('.test-welcome-text').getText(), /Learn more.*find an expert/);
+      assert.match(await driver.find('.test-welcome-text').getText(), /Learn more/);
       assert.notMatch(await driver.find('.test-welcome-text').getText(), /sign up/);
     });
 

From 4815a007edf46823b78b96d4a354fcbcc8ee3607 Mon Sep 17 00:00:00 2001
From: Paul Fitzpatrick <paulfitz@alum.mit.edu>
Date: Mon, 1 Jul 2024 10:24:16 -0400
Subject: [PATCH 029/145] log periodic per-document statistics about snapshot
 generation

This is to facilitate alerting to detect if snapshot generation were to
stall for a document.
---
 app/common/normalizedDateTimeString.ts | 27 ++++++++++++++++
 app/gen-server/lib/Housekeeper.ts      | 28 +----------------
 app/server/lib/ActiveDoc.ts            | 21 +++++++++++++
 app/server/lib/DocStorageManager.ts    |  6 +++-
 app/server/lib/HostedStorageManager.ts | 43 ++++++++++++++++++++++++--
 app/server/lib/IDocStorageManager.ts   | 42 +++++++++++++++++++++++++
 6 files changed, 137 insertions(+), 30 deletions(-)
 create mode 100644 app/common/normalizedDateTimeString.ts

diff --git a/app/common/normalizedDateTimeString.ts b/app/common/normalizedDateTimeString.ts
new file mode 100644
index 00000000..ad07c3bf
--- /dev/null
+++ b/app/common/normalizedDateTimeString.ts
@@ -0,0 +1,27 @@
+import moment from 'moment';
+
+/**
+ * Output an ISO8601 format datetime string, with timezone.
+ * Any string fed in without timezone is expected to be in UTC.
+ *
+ * When connected to postgres, dates will be extracted as Date objects,
+ * with timezone information. The normalization done here is not
+ * really needed in this case.
+ *
+ * Timestamps in SQLite are stored as UTC, and read as strings
+ * (without timezone information). The normalization here is
+ * pretty important in this case.
+ */
+export function normalizedDateTimeString(dateTime: any): string {
+  if (!dateTime) { return dateTime; }
+  if (dateTime instanceof Date) {
+    return moment(dateTime).toISOString();
+  }
+  if (typeof dateTime === 'string' || typeof dateTime === 'number') {
+    // When SQLite returns a string, it will be in UTC.
+    // Need to make sure it actually have timezone info in it
+    // (will not by default).
+    return moment.utc(dateTime).toISOString();
+  }
+  throw new Error(`normalizedDateTimeString cannot handle ${dateTime}`);
+}
diff --git a/app/gen-server/lib/Housekeeper.ts b/app/gen-server/lib/Housekeeper.ts
index 116a3c50..c40379fe 100644
--- a/app/gen-server/lib/Housekeeper.ts
+++ b/app/gen-server/lib/Housekeeper.ts
@@ -1,6 +1,7 @@
 import { ApiError } from 'app/common/ApiError';
 import { delay } from 'app/common/delay';
 import { buildUrlId } from 'app/common/gristUrls';
+import { normalizedDateTimeString } from 'app/common/normalizedDateTimeString';
 import { BillingAccount } from 'app/gen-server/entity/BillingAccount';
 import { Document } from 'app/gen-server/entity/Document';
 import { Organization } from 'app/gen-server/entity/Organization';
@@ -16,7 +17,6 @@ import log from 'app/server/lib/log';
 import { IPermitStore } from 'app/server/lib/Permit';
 import { optStringParam, stringParam } from 'app/server/lib/requestUtils';
 import * as express from 'express';
-import moment from 'moment';
 import fetch from 'node-fetch';
 import * as Fetch from 'node-fetch';
 import { EntityManager } from 'typeorm';
@@ -416,32 +416,6 @@ export class Housekeeper {
   }
 }
 
-/**
- * Output an ISO8601 format datetime string, with timezone.
- * Any string fed in without timezone is expected to be in UTC.
- *
- * When connected to postgres, dates will be extracted as Date objects,
- * with timezone information. The normalization done here is not
- * really needed in this case.
- *
- * Timestamps in SQLite are stored as UTC, and read as strings
- * (without timezone information). The normalization here is
- * pretty important in this case.
- */
-function normalizedDateTimeString(dateTime: any): string {
-  if (!dateTime) { return dateTime; }
-  if (dateTime instanceof Date) {
-    return moment(dateTime).toISOString();
-  }
-  if (typeof dateTime === 'string') {
-    // When SQLite returns a string, it will be in UTC.
-    // Need to make sure it actually have timezone info in it
-    // (will not by default).
-    return moment.utc(dateTime).toISOString();
-  }
-  throw new Error(`normalizedDateTimeString cannot handle ${dateTime}`);
-}
-
 /**
  * Call callback(item) for each item on the list, sleeping periodically to allow other works to
  * happen. Any time work takes more than SYNC_WORK_LIMIT_MS, will sleep for SYNC_WORK_BREAK_MS.
diff --git a/app/server/lib/ActiveDoc.ts b/app/server/lib/ActiveDoc.ts
index addf1278..c3fb5be0 100644
--- a/app/server/lib/ActiveDoc.ts
+++ b/app/server/lib/ActiveDoc.ts
@@ -69,6 +69,7 @@ import {commonUrls, parseUrlId} from 'app/common/gristUrls';
 import {byteString, countIf, retryOnce, safeJsonParse, timeoutReached} from 'app/common/gutil';
 import {InactivityTimer} from 'app/common/InactivityTimer';
 import {Interval} from 'app/common/Interval';
+import {normalizedDateTimeString} from 'app/common/normalizedDateTimeString';
 import {
   compilePredicateFormula,
   getPredicateFormulaProperties,
@@ -2496,6 +2497,23 @@ export class ActiveDoc extends EventEmitter {
     }
   }
 
+  private _logSnapshotProgress(docSession: OptDocSession) {
+    const snapshotProgress = this._docManager.storageManager.getSnapshotProgress(this.docName);
+    const lastWindowTime = (snapshotProgress.lastWindowStartedAt &&
+        snapshotProgress.lastWindowDoneAt &&
+        snapshotProgress.lastWindowDoneAt > snapshotProgress.lastWindowStartedAt) ?
+        snapshotProgress.lastWindowDoneAt : Date.now();
+    const delay = snapshotProgress.lastWindowStartedAt ?
+        lastWindowTime - snapshotProgress.lastWindowStartedAt : null;
+    this._log.debug(docSession, 'snapshot status', {
+      ...snapshotProgress,
+      lastChangeAt: normalizedDateTimeString(snapshotProgress.lastChangeAt),
+      lastWindowStartedAt: normalizedDateTimeString(snapshotProgress.lastWindowStartedAt),
+      lastWindowDoneAt: normalizedDateTimeString(snapshotProgress.lastWindowDoneAt),
+      delay,
+    });
+  }
+
   private _logDocMetrics(docSession: OptDocSession, triggeredBy: 'docOpen' | 'interval'| 'docClose') {
     this.logTelemetryEvent(docSession, 'documentUsage', {
       limited: {
@@ -2513,6 +2531,9 @@ export class ActiveDoc extends EventEmitter {
         ...this._getCustomWidgetMetrics(),
       },
     });
+    // Log progress on making snapshots periodically, to catch anything
+    // excessively slow.
+    this._logSnapshotProgress(docSession);
   }
 
   private _getAccessRuleMetrics() {
diff --git a/app/server/lib/DocStorageManager.ts b/app/server/lib/DocStorageManager.ts
index 24c8628e..dbcb0e59 100644
--- a/app/server/lib/DocStorageManager.ts
+++ b/app/server/lib/DocStorageManager.ts
@@ -11,7 +11,7 @@ import * as gutil from 'app/common/gutil';
 import {Comm} from 'app/server/lib/Comm';
 import * as docUtils from 'app/server/lib/docUtils';
 import {GristServer} from 'app/server/lib/GristServer';
-import {IDocStorageManager} from 'app/server/lib/IDocStorageManager';
+import {IDocStorageManager, SnapshotProgress} from 'app/server/lib/IDocStorageManager';
 import {IShell} from 'app/server/lib/IShell';
 import log from 'app/server/lib/log';
 import uuidv4 from "uuid/v4";
@@ -257,6 +257,10 @@ export class DocStorageManager implements IDocStorageManager {
     throw new Error('removeSnapshots not implemented');
   }
 
+  public getSnapshotProgress(): SnapshotProgress {
+    throw new Error('getSnapshotProgress not implemented');
+  }
+
   public async replace(docName: string, options: any): Promise<void> {
     throw new Error('replacement not implemented');
   }
diff --git a/app/server/lib/HostedStorageManager.ts b/app/server/lib/HostedStorageManager.ts
index 03f80138..9be39f85 100644
--- a/app/server/lib/HostedStorageManager.ts
+++ b/app/server/lib/HostedStorageManager.ts
@@ -15,7 +15,7 @@ import {IDocWorkerMap} from 'app/server/lib/DocWorkerMap';
 import {ChecksummedExternalStorage, DELETED_TOKEN, ExternalStorage, Unchanged} from 'app/server/lib/ExternalStorage';
 import {HostedMetadataManager} from 'app/server/lib/HostedMetadataManager';
 import {ICreate} from 'app/server/lib/ICreate';
-import {IDocStorageManager} from 'app/server/lib/IDocStorageManager';
+import {IDocStorageManager, SnapshotProgress} from 'app/server/lib/IDocStorageManager';
 import {LogMethods} from "app/server/lib/LogMethods";
 import {fromCallback} from 'app/server/lib/serverUtils';
 import * as fse from 'fs-extra';
@@ -94,6 +94,9 @@ export class HostedStorageManager implements IDocStorageManager {
   // Time at which document was last changed.
   private _timestamps = new Map<string, string>();
 
+  // Statistics related to snapshot generation.
+  private _snapshotProgress = new Map<string, SnapshotProgress>();
+
   // Access external storage.
   private _ext: ChecksummedExternalStorage;
   private _extMeta: ChecksummedExternalStorage;
@@ -223,6 +226,25 @@ export class HostedStorageManager implements IDocStorageManager {
     return path.basename(altDocName, '.grist');
   }
 
+  /**
+   * Read some statistics related to generating snapshots.
+   */
+  public getSnapshotProgress(docName: string): SnapshotProgress {
+    let snapshotProgress = this._snapshotProgress.get(docName);
+    if (!snapshotProgress) {
+      snapshotProgress = {
+        pushes: 0,
+        skippedPushes: 0,
+        errors: 0,
+        changes: 0,
+        windowsStarted: 0,
+        windowsDone: 0,
+      };
+      this._snapshotProgress.set(docName, snapshotProgress);
+    }
+    return snapshotProgress;
+  }
+
   /**
    * Prepares a document for use locally. Here we sync the doc from S3 to the local filesystem.
    * Returns whether the document is new (needs to be created).
@@ -476,7 +498,11 @@ export class HostedStorageManager implements IDocStorageManager {
    * This is called when a document may have been changed, via edits or migrations etc.
    */
   public markAsChanged(docName: string, reason?: string): void {
-    const timestamp = new Date().toISOString();
+    const now = new Date();
+    const snapshotProgress = this.getSnapshotProgress(docName);
+    snapshotProgress.lastChangeAt = now.getTime();
+    snapshotProgress.changes++;
+    const timestamp = now.toISOString();
     this._timestamps.set(docName, timestamp);
     try {
       if (parseUrlId(docName).snapshotId) { return; }
@@ -486,6 +512,10 @@ export class HostedStorageManager implements IDocStorageManager {
       }
       if (this._disableS3) { return; }
       if (this._closed) { throw new Error("HostedStorageManager.markAsChanged called after closing"); }
+      if (!this._uploads.hasPendingOperation(docName)) {
+        snapshotProgress.lastWindowStartedAt = now.getTime();
+        snapshotProgress.windowsStarted++;
+      }
       this._uploads.addOperation(docName);
     } finally {
       if (reason === 'edit') {
@@ -729,6 +759,7 @@ export class HostedStorageManager implements IDocStorageManager {
   private async _pushToS3(docId: string): Promise<void> {
     let tmpPath: string|null = null;
 
+    const snapshotProgress = this.getSnapshotProgress(docId);
     try {
       if (this._prepareFiles.has(docId)) {
         throw new Error('too soon to consider pushing');
@@ -748,14 +779,18 @@ export class HostedStorageManager implements IDocStorageManager {
       await this._inventory.uploadAndAdd(docId, async () => {
         const prevSnapshotId = this._latestVersions.get(docId) || null;
         const newSnapshotId = await this._ext.upload(docId, tmpPath as string, metadata);
+        snapshotProgress.lastWindowDoneAt = Date.now();
+        snapshotProgress.windowsDone++;
         if (newSnapshotId === Unchanged) {
           // Nothing uploaded because nothing changed
+          snapshotProgress.skippedPushes++;
           return { prevSnapshotId };
         }
         if (!newSnapshotId) {
           // This is unexpected.
           throw new Error('No snapshotId allocated after upload');
         }
+        snapshotProgress.pushes++;
         const snapshot = {
           lastModified: t,
           snapshotId: newSnapshotId,
@@ -767,6 +802,10 @@ export class HostedStorageManager implements IDocStorageManager {
       if (changeMade) {
         await this._onInventoryChange(docId);
       }
+    } catch (e) {
+      snapshotProgress.errors++;
+      // Snapshot window completion time deliberately not set.
+      throw e;
     } finally {
       // Clean up backup.
       // NOTE: fse.remove succeeds also when the file does not exist.
diff --git a/app/server/lib/IDocStorageManager.ts b/app/server/lib/IDocStorageManager.ts
index 54180a98..a7eba540 100644
--- a/app/server/lib/IDocStorageManager.ts
+++ b/app/server/lib/IDocStorageManager.ts
@@ -36,6 +36,8 @@ export interface IDocStorageManager {
   // Metadata may not be returned in this case.
   getSnapshots(docName: string, skipMetadataCache?: boolean): Promise<DocSnapshots>;
   removeSnapshots(docName: string, snapshotIds: string[]): Promise<void>;
+  // Get information about how snapshot generation is going.
+  getSnapshotProgress(docName: string): SnapshotProgress;
   replace(docName: string, options: DocReplacementOptions): Promise<void>;
 }
 
@@ -66,5 +68,45 @@ export class TrivialDocStorageManager implements IDocStorageManager {
   public async flushDoc() {}
   public async getSnapshots(): Promise<never> { throw new Error('no'); }
   public async removeSnapshots(): Promise<never> { throw new Error('no'); }
+  public getSnapshotProgress(): SnapshotProgress { throw new Error('no'); }
   public async replace(): Promise<never> { throw new Error('no'); }
 }
+
+
+/**
+ * Some summary information about how snapshot generation is going.
+ * Any times are in ms.
+ * All information is within the lifetime of a doc worker, not global.
+ */
+export interface SnapshotProgress {
+  // The last time the document was marked as having changed.
+  lastChangeAt?: number;
+
+  // The last time a save window started for the document (checking to see
+  // if it needs to be pushed, and pushing it if so, possibly waiting
+  // quite some time to bundle any other changes).
+  lastWindowStartedAt?: number;
+
+  // The last time the document was either pushed or determined to not
+  // actually need to be pushed, after having been marked as changed.
+  lastWindowDoneAt?: number;
+
+  // Number of times the document was pushed.
+  pushes: number;
+
+  // Number of times the document was not pushed because no change found.
+  skippedPushes: number;
+
+  // Number of times there was an error trying to push.
+  errors: number;
+
+  // Number of times the document was marked as changed.
+  // Will generally be a lot greater than saves.
+  changes: number;
+
+  // Number of times a save window was started.
+  windowsStarted: number;
+
+  // Number of times a save window was completed.
+  windowsDone: number;
+}

From 95b8134614e093ca114e166ff8a9717af6297488 Mon Sep 17 00:00:00 2001
From: Paul Fitzpatrick <paulfitz@alum.mit.edu>
Date: Tue, 2 Jul 2024 06:52:57 -0400
Subject: [PATCH 030/145] add a getSnapshotProgress implementation to
 DocStorageManager

---
 app/server/lib/DocStorageManager.ts | 9 ++++++++-
 1 file changed, 8 insertions(+), 1 deletion(-)

diff --git a/app/server/lib/DocStorageManager.ts b/app/server/lib/DocStorageManager.ts
index dbcb0e59..8879db42 100644
--- a/app/server/lib/DocStorageManager.ts
+++ b/app/server/lib/DocStorageManager.ts
@@ -258,7 +258,14 @@ export class DocStorageManager implements IDocStorageManager {
   }
 
   public getSnapshotProgress(): SnapshotProgress {
-    throw new Error('getSnapshotProgress not implemented');
+    return {
+      pushes: 0,
+      skippedPushes: 0,
+      errors: 0,
+      changes: 0,
+      windowsStarted: 0,
+      windowsDone: 0,
+    };
   }
 
   public async replace(docName: string, options: any): Promise<void> {

From 5f9ecdcfe4b5589ffb14590e796977e56ce41d36 Mon Sep 17 00:00:00 2001
From: Paul Fitzpatrick <paulfitz@alum.mit.edu>
Date: Wed, 3 Jul 2024 11:16:42 -0400
Subject: [PATCH 031/145] docstrings, moment import, fix log format

---
 app/common/normalizedDateTimeString.ts |  2 +-
 app/server/lib/ActiveDoc.ts            |  3 ++-
 app/server/lib/IDocStorageManager.ts   | 32 +++++++++++++++-----------
 3 files changed, 22 insertions(+), 15 deletions(-)

diff --git a/app/common/normalizedDateTimeString.ts b/app/common/normalizedDateTimeString.ts
index ad07c3bf..5197d784 100644
--- a/app/common/normalizedDateTimeString.ts
+++ b/app/common/normalizedDateTimeString.ts
@@ -1,4 +1,4 @@
-import moment from 'moment';
+import moment from 'moment-timezone';
 
 /**
  * Output an ISO8601 format datetime string, with timezone.
diff --git a/app/server/lib/ActiveDoc.ts b/app/server/lib/ActiveDoc.ts
index c3fb5be0..e4e9d7a0 100644
--- a/app/server/lib/ActiveDoc.ts
+++ b/app/server/lib/ActiveDoc.ts
@@ -2505,7 +2505,8 @@ export class ActiveDoc extends EventEmitter {
         snapshotProgress.lastWindowDoneAt : Date.now();
     const delay = snapshotProgress.lastWindowStartedAt ?
         lastWindowTime - snapshotProgress.lastWindowStartedAt : null;
-    this._log.debug(docSession, 'snapshot status', {
+    log.rawInfo('snapshot status', {
+      ...this.getLogMeta(docSession),
       ...snapshotProgress,
       lastChangeAt: normalizedDateTimeString(snapshotProgress.lastChangeAt),
       lastWindowStartedAt: normalizedDateTimeString(snapshotProgress.lastWindowStartedAt),
diff --git a/app/server/lib/IDocStorageManager.ts b/app/server/lib/IDocStorageManager.ts
index a7eba540..6bde92cb 100644
--- a/app/server/lib/IDocStorageManager.ts
+++ b/app/server/lib/IDocStorageManager.ts
@@ -79,34 +79,40 @@ export class TrivialDocStorageManager implements IDocStorageManager {
  * All information is within the lifetime of a doc worker, not global.
  */
 export interface SnapshotProgress {
-  // The last time the document was marked as having changed.
+  /** The last time the document was marked as having changed. */
   lastChangeAt?: number;
 
-  // The last time a save window started for the document (checking to see
-  // if it needs to be pushed, and pushing it if so, possibly waiting
-  // quite some time to bundle any other changes).
+  /**
+   * The last time a save window started for the document (checking to see
+   * if it needs to be pushed, and pushing it if so, possibly waiting
+   * quite some time to bundle any other changes).
+   */
   lastWindowStartedAt?: number;
 
-  // The last time the document was either pushed or determined to not
-  // actually need to be pushed, after having been marked as changed.
+  /**
+   * The last time the document was either pushed or determined to not
+   * actually need to be pushed, after having been marked as changed.
+   */
   lastWindowDoneAt?: number;
 
-  // Number of times the document was pushed.
+  /** Number of times the document was pushed. */
   pushes: number;
 
-  // Number of times the document was not pushed because no change found.
+  /** Number of times the document was not pushed because no change found. */
   skippedPushes: number;
 
-  // Number of times there was an error trying to push.
+  /** Number of times there was an error trying to push. */
   errors: number;
 
-  // Number of times the document was marked as changed.
-  // Will generally be a lot greater than saves.
+  /**
+   * Number of times the document was marked as changed.
+   * Will generally be a lot greater than saves.
+   */
   changes: number;
 
-  // Number of times a save window was started.
+  /** Number of times a save window was started. */
   windowsStarted: number;
 
-  // Number of times a save window was completed.
+  /** Number of times a save window was completed. */
   windowsDone: number;
 }

From 2750ed6bd9831510d6169a5500df8398650c4c86 Mon Sep 17 00:00:00 2001
From: Leslie H <142967379+SleepyLeslie@users.noreply.github.com>
Date: Wed, 3 Jul 2024 15:36:17 -0400
Subject: [PATCH 032/145] Enable external contributors to create previews
 (#1068)

Reorganize preview workflows so that previews can be made for PRs from outside contributors.
---
 .github/workflows/fly-build.yml   | 43 +++++++++++++++
 .github/workflows/fly-cleanup.yml | 20 +++----
 .github/workflows/fly-deploy.yml  | 70 +++++++++++++++++++++++
 .github/workflows/fly-destroy.yml | 36 ++++++++++++
 .github/workflows/fly.yml         | 64 ---------------------
 buildtools/fly-deploy.js          | 92 ++++++++++++++++++-------------
 buildtools/fly-template.toml      |  5 ++
 7 files changed, 217 insertions(+), 113 deletions(-)
 create mode 100644 .github/workflows/fly-build.yml
 create mode 100644 .github/workflows/fly-deploy.yml
 create mode 100644 .github/workflows/fly-destroy.yml
 delete mode 100644 .github/workflows/fly.yml

diff --git a/.github/workflows/fly-build.yml b/.github/workflows/fly-build.yml
new file mode 100644
index 00000000..26c5fee5
--- /dev/null
+++ b/.github/workflows/fly-build.yml
@@ -0,0 +1,43 @@
+# fly-deploy will be triggered on completion of this workflow to actually deploy the code to fly.io.
+
+name: fly.io Build
+on:
+  pull_request:
+    branches: [ main ]
+    types: [labeled, opened, synchronize, reopened]
+
+  # Allows running this workflow manually from the Actions tab
+  workflow_dispatch:
+
+jobs:
+  build:
+    name: Build Docker image
+    runs-on: ubuntu-latest
+    # Build when the 'preview' label is added, or when PR is updated with this label present.
+    if: >
+      github.event_name == 'workflow_dispatch' ||
+      (github.event_name == 'pull_request' &&
+      contains(github.event.pull_request.labels.*.name, 'preview'))
+    steps:
+      - uses: actions/checkout@v4
+      - name: Build and export Docker image
+        id: docker-build
+        run: >
+          docker build -t grist-core:preview . &&
+          docker image save grist-core:preview -o grist-core.tar
+      - name: Save PR information
+        run: |
+          echo PR_NUMBER=${{ github.event.number }} >> ./pr-info.txt
+          echo PR_SOURCE=${{ github.event.pull_request.head.repo.full_name }}-${{ github.event.pull_request.head.ref }} >> ./pr-info.txt
+          echo PR_SHASUM=${{ github.event.pull_request.head.sha }} >> ./pr-info.txt
+        # PR_SOURCE looks like <owner>/<repo>-<branch>.
+        # For example, if the GitHub user "foo" forked grist-core as "grist-bar", and makes a PR from their branch named "baz",
+        # it will be "foo/grist-bar-baz". deploy.js later replaces "/" with "-", making it "foo-grist-bar-baz".
+      - name: Upload artifact
+        uses: actions/upload-artifact@v4
+        with:
+          name: docker-image
+          path: |
+            ./grist-core.tar
+            ./pr-info.txt
+          if-no-files-found: "error"
diff --git a/.github/workflows/fly-cleanup.yml b/.github/workflows/fly-cleanup.yml
index 256d2f0f..6250e589 100644
--- a/.github/workflows/fly-cleanup.yml
+++ b/.github/workflows/fly-cleanup.yml
@@ -1,4 +1,4 @@
-name: Fly Cleanup
+name: fly.io Cleanup
 on:
   schedule:
     # Once a day, clean up jobs marked as expired
@@ -12,12 +12,12 @@ env:
 
 jobs:
   clean:
-      name: Clean stale deployed apps
-      runs-on: ubuntu-latest
-      if: github.repository_owner == 'gristlabs'
-      steps:
-        - uses: actions/checkout@v3
-        - uses: superfly/flyctl-actions/setup-flyctl@master
-          with:
-            version: 0.1.66
-        - run: node buildtools/fly-deploy.js clean
+    name: Clean stale deployed apps
+    runs-on: ubuntu-latest
+    if: github.repository_owner == 'gristlabs'
+    steps:
+      - uses: actions/checkout@v3
+      - uses: superfly/flyctl-actions/setup-flyctl@master
+        with:
+          version: 0.2.72
+      - run: node buildtools/fly-deploy.js clean
diff --git a/.github/workflows/fly-deploy.yml b/.github/workflows/fly-deploy.yml
new file mode 100644
index 00000000..5a4c0711
--- /dev/null
+++ b/.github/workflows/fly-deploy.yml
@@ -0,0 +1,70 @@
+# Follow-up of fly-build, with access to secrets for making deployments.
+# This workflow runs in the target repo context. It does not, and should never execute user-supplied code.
+# See https://securitylab.github.com/research/github-actions-preventing-pwn-requests/
+
+name: fly.io Deploy
+on:
+  workflow_run:
+    workflows: ["fly.io Build"]
+    types:
+      - completed
+
+jobs:
+  deploy:
+    name: Deploy app to fly.io
+    runs-on: ubuntu-latest
+    if: |
+      github.event.workflow_run.event == 'pull_request' &&
+      github.event.workflow_run.conclusion == 'success'
+    steps:
+      - uses: actions/checkout@v4
+      - name: Set up flyctl
+        uses: superfly/flyctl-actions/setup-flyctl@master
+        with:
+          version: 0.2.72
+      - name: Download artifacts
+        uses: actions/github-script@v7
+        with:
+          script: |
+            var artifacts = await github.rest.actions.listWorkflowRunArtifacts({
+               owner: context.repo.owner,
+               repo: context.repo.repo,
+               run_id: ${{ github.event.workflow_run.id }},
+            });
+            var matchArtifact = artifacts.data.artifacts.filter((artifact) => {
+              return artifact.name == "docker-image"
+            })[0];
+            var download = await github.rest.actions.downloadArtifact({
+               owner: context.repo.owner,
+               repo: context.repo.repo,
+               artifact_id: matchArtifact.id,
+               archive_format: 'zip',
+            });
+            var fs = require('fs');
+            fs.writeFileSync('${{github.workspace}}/docker-image.zip', Buffer.from(download.data));
+      - name: Extract artifacts
+        id: extract_artifacts
+        run: |
+          unzip docker-image.zip
+          cat ./pr-info.txt >> $GITHUB_OUTPUT
+      - name: Load Docker image
+        run: docker load --input grist-core.tar
+      - name: Deploy to fly.io
+        id: fly_deploy
+        env:
+          FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
+          BRANCH_NAME: ${{ steps.extract_artifacts.outputs.PR_SOURCE }}
+        run: |
+          node buildtools/fly-deploy.js deploy
+          flyctl config -c ./fly.toml env | awk '/APP_HOME_URL/{print "DEPLOY_URL=" $2}' >> $GITHUB_OUTPUT
+          flyctl config -c ./fly.toml env | awk '/FLY_DEPLOY_EXPIRATION/{print "EXPIRES=" $2}' >> $GITHUB_OUTPUT
+      - name: Comment on PR
+        uses: actions/github-script@v7
+        with:
+          script: |
+            github.rest.issues.createComment({
+              issue_number: ${{ steps.extract_artifacts.outputs.PR_NUMBER }},
+              owner: context.repo.owner,
+              repo: context.repo.repo,
+              body: `Deployed commit \`${{ steps.extract_artifacts.outputs.PR_SHASUM }}\` as ${{ steps.fly_deploy.outputs.DEPLOY_URL }} (until ${{ steps.fly_deploy.outputs.EXPIRES }})`
+            })
diff --git a/.github/workflows/fly-destroy.yml b/.github/workflows/fly-destroy.yml
new file mode 100644
index 00000000..1fe204a7
--- /dev/null
+++ b/.github/workflows/fly-destroy.yml
@@ -0,0 +1,36 @@
+# This workflow runs in the target repo context, as it is triggered via pull_request_target.
+# It does not, and should not have access to code in the PR.
+# See https://securitylab.github.com/research/github-actions-preventing-pwn-requests/
+
+name: fly.io Destroy
+on:
+  pull_request_target:
+    branches: [ main ]
+    types: [unlabeled, closed]
+
+  # Allows running this workflow manually from the Actions tab
+  workflow_dispatch:
+
+jobs:
+  destroy:
+    name: Remove app from fly.io
+    runs-on: ubuntu-latest
+    # Remove the deployment when 'preview' label is removed, or the PR is closed.
+    if: |
+      github.event_name == 'workflow_dispatch' ||
+      (github.event_name == 'pull_request_target' &&
+      (github.event.action == 'closed' ||
+      (github.event.action == 'unlabeled' && github.event.label.name == 'preview')))
+    steps:
+      - uses: actions/checkout@v4
+      - name: Set up flyctl
+        uses: superfly/flyctl-actions/setup-flyctl@master
+        with:
+          version: 0.2.72
+      - name: Destroy fly.io app
+        env:
+          FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
+          BRANCH_NAME: ${{ github.event.pull_request.head.repo.full_name }}-${{ github.event.pull_request.head.ref }}
+          # See fly-build for what BRANCH_NAME looks like.
+        id: fly_destroy
+        run: node buildtools/fly-deploy.js destroy
diff --git a/.github/workflows/fly.yml b/.github/workflows/fly.yml
deleted file mode 100644
index 5f7d10b8..00000000
--- a/.github/workflows/fly.yml
+++ /dev/null
@@ -1,64 +0,0 @@
-name: Fly Deploy
-on:
-  pull_request:
-    branches: [ main ]
-    types: [labeled, unlabeled, closed, opened, synchronize, reopened]
-
-  # Allows running this workflow manually from the Actions tab
-  workflow_dispatch:
-
-env:
-  FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
-  BRANCH_NAME: ${{ github.head_ref || github.ref_name }}
-
-jobs:
-  deploy:
-    name: Deploy app
-    runs-on: ubuntu-latest
-    # Deploy when the 'preview' label is added, or when PR is updated with this label present.
-    if: |
-          github.repository_owner == 'gristlabs' &&
-          github.event_name == 'pull_request' && (
-            github.event.action == 'labeled' ||
-            github.event.action == 'opened' ||
-            github.event.action == 'synchronize' ||
-            github.event.action == 'reopened'
-          ) &&
-          contains(github.event.pull_request.labels.*.name, 'preview')
-    steps:
-      - uses: actions/checkout@v3
-      - uses: superfly/flyctl-actions/setup-flyctl@master
-        with:
-          version: 0.1.89
-      - id: fly_deploy
-        run: |
-          node buildtools/fly-deploy.js deploy
-          flyctl config -c ./fly.toml env | awk '/APP_HOME_URL/{print "DEPLOY_URL=" $2}' >> $GITHUB_OUTPUT
-          flyctl config -c ./fly.toml env | awk '/FLY_DEPLOY_EXPIRATION/{print "EXPIRES=" $2}' >> $GITHUB_OUTPUT
-
-      - uses: actions/github-script@v6
-        with:
-          script: |
-            github.rest.issues.createComment({
-              issue_number: context.issue.number,
-              owner: context.repo.owner,
-              repo: context.repo.repo,
-              body: `Deployed as ${{ steps.fly_deploy.outputs.DEPLOY_URL }} (until ${{ steps.fly_deploy.outputs.EXPIRES }})`
-            })
-
-  destroy:
-    name: Remove app
-    runs-on: ubuntu-latest
-    # Remove the deployment when 'preview' label is removed, or the PR is closed.
-    if: |
-          github.repository_owner == 'gristlabs' &&
-          github.event_name == 'pull_request' &&
-          (github.event.action == 'closed' ||
-           (github.event.action == 'unlabeled' && github.event.label.name == 'preview'))
-    steps:
-      - uses: actions/checkout@v3
-      - uses: superfly/flyctl-actions/setup-flyctl@master
-        with:
-          version: 0.1.89
-      - id: fly_destroy
-        run: node buildtools/fly-deploy.js destroy
diff --git a/buildtools/fly-deploy.js b/buildtools/fly-deploy.js
index e5432f29..f2bb5c8e 100644
--- a/buildtools/fly-deploy.js
+++ b/buildtools/fly-deploy.js
@@ -1,7 +1,6 @@
 const util = require('util');
 const childProcess = require('child_process');
 const fs = require('fs/promises');
-const {existsSync} = require('fs');
 
 const exec = util.promisify(childProcess.exec);
 
@@ -17,66 +16,81 @@ const getBranchName = () => {
 };
 
 async function main() {
-  if (process.argv[2] === 'deploy') {
-    const appRoot = process.argv[3] || ".";
-    if (!existsSync(`${appRoot}/Dockerfile`)) {
-      console.log(`Dockerfile not found in appRoot of ${appRoot}`);
-      process.exit(1);
-    }
-
-    const name = getAppName();
-    const volName = getVolumeName();
-    if (!await appExists(name)) {
-      await appCreate(name);
-      await volCreate(name, volName);
-    } else {
-      // Check if volume exists, and create it if not. This is needed because there was an API
-      // change in flyctl (mandatory -y flag) and some apps were created without a volume.
-      if (!(await volList(name)).length) {
+  switch (process.argv[2]) {
+    case "deploy": {
+      const name = getAppName();
+      const volName = getVolumeName();
+      if (!await appExists(name)) {
+        await appCreate(name);
         await volCreate(name, volName);
+      } else {
+        // Check if volume exists, and create it if not. This is needed because there was an API
+        // change in flyctl (mandatory -y flag) and some apps were created without a volume.
+        if (!(await volList(name)).length) {
+          await volCreate(name, volName);
+        }
       }
+      await prepConfig(name, volName);
+      await appDeploy(name);
+      break;
     }
-    await prepConfig(name, appRoot, volName);
-    await appDeploy(name, appRoot);
-  } else if (process.argv[2] === 'destroy') {
-    const name = getAppName();
-    if (await appExists(name)) {
-      await appDestroy(name);
+    case "destroy": {
+      const name = getAppName();
+      if (await appExists(name)) {
+        await appDestroy(name);
+      }
+      break;
     }
-  } else if (process.argv[2] === 'clean') {
-    const staleApps = await findStaleApps();
-    for (const appName of staleApps) {
-      await appDestroy(appName);
+    case "clean": {
+      const staleApps = await findStaleApps();
+      for (const appName of staleApps) {
+        await appDestroy(appName);
+      }
+      break;
     }
-  } else {
-    console.log(`Usage:
-  deploy [appRoot]:
-            create (if needed) and deploy fly app grist-{BRANCH_NAME}.
-            appRoot may specify the working directory that contains the Dockerfile to build.
+    default: {
+      console.log(`Usage:
+  deploy:   create (if needed) and deploy fly app grist-{BRANCH_NAME}.
   destroy:  destroy fly app grist-{BRANCH_NAME}
   clean:    destroy all grist-* fly apps whose time has come
             (according to FLY_DEPLOY_EXPIRATION env var set at deploy time)
 
   DRYRUN=1 in environment will show what would be done
 `);
-    process.exit(1);
+      process.exit(1);
+    }
   }
 }
 
+function getDockerTag(name) {
+  return `registry.fly.io/${name}:latest`;
+}
+
 const appExists = (name) => runFetch(`flyctl status -a ${name}`).then(() => true).catch(() => false);
-const appCreate = (name) => runAction(`flyctl launch --auto-confirm --name ${name} -r ewr -o ${org} --vm-memory 1024`);
+// We do not deploy at the create stage, since the Docker image isn't ready yet.
+// Assigning --image prevents flyctl from making inferences based on the codebase and provisioning unnecessary postgres/redis instances.
+const appCreate = (name) => runAction(`flyctl launch --no-deploy --auto-confirm --image ${getDockerTag(name)} --name ${name} -r ewr -o ${org}`);
 const volCreate = (name, vol) => runAction(`flyctl volumes create ${vol} -s 1 -r ewr -y -a ${name}`);
 const volList = (name) => runFetch(`flyctl volumes list -a ${name} -j`).then(({stdout}) => JSON.parse(stdout));
-const appDeploy = (name, appRoot) => runAction(`flyctl deploy ${appRoot} --remote-only --region=ewr --vm-memory 1024`,
-  {shell: true, stdio: 'inherit'});
+const appDeploy = async (name) => {
+  try {
+    await runAction("flyctl auth docker")
+    await runAction(`docker image tag grist-core:preview ${getDockerTag(name)}`);
+    await runAction(`docker push ${getDockerTag(name)}`);
+    await runAction(`flyctl deploy --app ${name} --image ${getDockerTag(name)}`);
+  } catch (e) {
+    console.log(`Error occurred when deploying: ${e}`);
+    process.exit(1);
+  }
+};
 
 async function appDestroy(name) {
   await runAction(`flyctl apps destroy ${name} -y`);
 }
 
-async function prepConfig(name, appRoot, volName) {
-  const configPath = `${appRoot}/fly.toml`;
-  const configTemplatePath = `${appRoot}/buildtools/fly-template.toml`;
+async function prepConfig(name, volName) {
+  const configPath = "./fly.toml";
+  const configTemplatePath = "./buildtools/fly-template.toml";
   const template = await fs.readFile(configTemplatePath, {encoding: 'utf8'});
 
   // Calculate the time when we can destroy the app, used by findStaleApps.
diff --git a/buildtools/fly-template.toml b/buildtools/fly-template.toml
index 4eba32ff..b1b2a807 100644
--- a/buildtools/fly-template.toml
+++ b/buildtools/fly-template.toml
@@ -48,3 +48,8 @@ processes = []
 [mounts]
 source="{VOLUME_NAME}"
 destination="/persist"
+
+[[vm]]
+  memory = '1gb'
+  cpu_kind = 'shared'
+  cpus = 1

From 0bfdaa9c02c761b3c24e83c57960e015c8796614 Mon Sep 17 00:00:00 2001
From: CamilleLegeron <camille@telescoop.fr>
Date: Thu, 4 Jul 2024 14:17:10 +0200
Subject: [PATCH 033/145] Add authorization header in webhooks stored in
 secrets table (#941)

Summary:
Adding authorization header support for webhooks.

Issue:  https://github.com/gristlabs/grist-core/issues/827

---------

Co-authored-by: Florent <florent.git@zeteo.me>
---
 app/client/ui/WebhookPage.ts        | 17 ++++++++++++-----
 app/common/Triggers-ti.ts           |  4 ++++
 app/common/Triggers.ts              |  4 ++++
 app/gen-server/lib/HomeDBManager.ts | 24 +++++++++++++++++++++---
 app/server/lib/DocApi.ts            | 15 ++++++++-------
 app/server/lib/Triggers.ts          | 12 ++++++++++--
 static/locales/en.client.json       |  3 ++-
 test/nbrowser/WebhookPage.ts        | 28 +++++++++++++++++++++++++---
 test/server/lib/DocApi.ts           |  5 +++++
 9 files changed, 91 insertions(+), 21 deletions(-)

diff --git a/app/client/ui/WebhookPage.ts b/app/client/ui/WebhookPage.ts
index 9e263fc0..ff93061c 100644
--- a/app/client/ui/WebhookPage.ts
+++ b/app/client/ui/WebhookPage.ts
@@ -107,6 +107,12 @@ const WEBHOOK_COLUMNS = [
     type: 'Text',
     label: t('Status'),
   },
+  {
+    id: VirtualId(),
+    colId: 'authorization',
+    type: 'Text',
+    label: t('Header Authorization'),
+  },
 ] as const;
 
 /**
@@ -114,10 +120,11 @@ const WEBHOOK_COLUMNS = [
  */
 const WEBHOOK_VIEW_FIELDS: Array<(typeof WEBHOOK_COLUMNS)[number]['colId']> = [
   'name', 'memo',
-  'eventTypes', 'url',
-  'tableId', 'isReadyColumn',
-  'watchedColIdsText', 'webhookId',
-  'enabled', 'status'
+  'eventTypes', 'tableId',
+  'watchedColIdsText', 'isReadyColumn',
+  'url', 'authorization',
+  'webhookId', 'enabled',
+  'status'
 ];
 
 /**
@@ -136,7 +143,7 @@ class WebhookExternalTable implements IExternalTable {
   public name = 'GristHidden_WebhookTable';
   public initialActions = _prepareWebhookInitialActions(this.name);
   public saveableFields = [
-    'tableId', 'watchedColIdsText', 'url', 'eventTypes', 'enabled', 'name', 'memo', 'isReadyColumn',
+    'tableId', 'watchedColIdsText', 'url', 'authorization', 'eventTypes', 'enabled', 'name', 'memo', 'isReadyColumn',
   ];
   public webhooks: ObservableArray<UIWebhookSummary> = observableArray<UIWebhookSummary>([]);
 
diff --git a/app/common/Triggers-ti.ts b/app/common/Triggers-ti.ts
index bb04bbae..f93d12ae 100644
--- a/app/common/Triggers-ti.ts
+++ b/app/common/Triggers-ti.ts
@@ -14,6 +14,7 @@ export const Webhook = t.iface([], {
 
 export const WebhookFields = t.iface([], {
   "url": "string",
+  "authorization": t.opt("string"),
   "eventTypes": t.array(t.union(t.lit("add"), t.lit("update"))),
   "tableId": "string",
   "watchedColIds": t.opt(t.array("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",
+  "authorization": t.opt("string"),
   "eventTypes": t.array(t.union(t.lit("add"), t.lit("update"))),
   "watchedColIds": t.opt(t.array("string")),
   "enabled": t.opt("boolean"),
@@ -45,6 +47,7 @@ export const WebhookSummary = t.iface([], {
   "id": "string",
   "fields": t.iface([], {
     "url": "string",
+    "authorization": t.opt("string"),
     "unsubscribeKey": "string",
     "eventTypes": t.array("string"),
     "isReadyColumn": t.union("string", "null"),
@@ -64,6 +67,7 @@ export const WebhookUpdate = t.iface([], {
 
 export const WebhookPatch = t.iface([], {
   "url": t.opt("string"),
+  "authorization": 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")),
diff --git a/app/common/Triggers.ts b/app/common/Triggers.ts
index d3b492d6..a53dd1fe 100644
--- a/app/common/Triggers.ts
+++ b/app/common/Triggers.ts
@@ -8,6 +8,7 @@ export interface Webhook {
 
 export interface WebhookFields {
   url: string;
+  authorization?: string;
   eventTypes: Array<"add"|"update">;
   tableId: string;
   watchedColIds?: string[];
@@ -26,6 +27,7 @@ export type WebhookStatus = 'idle'|'sending'|'retrying'|'postponed'|'error'|'inv
 // tableId from the url) but generics are not yet supported by ts-interface-builder
 export interface WebhookSubscribe {
   url: string;
+  authorization?: string;
   eventTypes: Array<"add"|"update">;
   watchedColIds?: string[];
   enabled?: boolean;
@@ -42,6 +44,7 @@ export interface WebhookSummary {
   id: string;
   fields: {
     url: string;
+    authorization?: string;
     unsubscribeKey: string;
     eventTypes: string[];
     isReadyColumn: string|null;
@@ -64,6 +67,7 @@ export interface WebhookUpdate {
 // ts-interface-builder
 export interface WebhookPatch {
   url?: string;
+  authorization?: string;
   eventTypes?: Array<"add"|"update">;
   tableId?: string;
   watchedColIds?: string[];
diff --git a/app/gen-server/lib/HomeDBManager.ts b/app/gen-server/lib/HomeDBManager.ts
index 06409d1c..a4f89721 100644
--- a/app/gen-server/lib/HomeDBManager.ts
+++ b/app/gen-server/lib/HomeDBManager.ts
@@ -1608,7 +1608,7 @@ export class HomeDBManager extends EventEmitter {
       .where("id = :id AND doc_id = :docId", {id, docId})
       .execute();
     if (res.affected !== 1) {
-      throw new ApiError('secret with given id not found', 404);
+      throw new ApiError('secret with given id not found or nothing was updated', 404);
     }
   }
 
@@ -1623,14 +1623,32 @@ export class HomeDBManager extends EventEmitter {
 
   // Update the webhook url in the webhook's corresponding secret (note: the webhook identifier is
   // its secret identifier).
-  public async updateWebhookUrl(id: string, docId: string, url: string, outerManager?: EntityManager) {
+  public async updateWebhookUrlAndAuth(
+    props: {
+      id: string,
+      docId: string,
+      url: string | undefined,
+      auth: string | undefined,
+      outerManager?: EntityManager}
+    ) {
+    const {id, docId, url, auth, outerManager} = props;
     return await this._runInTransaction(outerManager, async manager => {
+      if (url === undefined && auth === undefined) {
+        throw new ApiError('None of the Webhook url and auth are defined', 404);
+      }
       const value = await this.getSecret(id, docId, manager);
       if (!value) {
         throw new ApiError('Webhook with given id not found', 404);
       }
       const webhookSecret = JSON.parse(value);
-      webhookSecret.url = url;
+      // As we want to patch the webhookSecret object, only set the url and the authorization when they are defined.
+      // When the user wants to empty the value, we are expected to receive empty strings.
+      if (url !== undefined) {
+        webhookSecret.url = url;
+      }
+      if (auth !== undefined) {
+        webhookSecret.authorization = auth;
+      }
       await this.updateSecret(id, docId, JSON.stringify(webhookSecret), manager);
     });
   }
diff --git a/app/server/lib/DocApi.ts b/app/server/lib/DocApi.ts
index e5f66df4..297cafff 100644
--- a/app/server/lib/DocApi.ts
+++ b/app/server/lib/DocApi.ts
@@ -324,7 +324,7 @@ export class DocWorkerApi {
     );
 
     const registerWebhook = async (activeDoc: ActiveDoc, req: RequestWithLogin, webhook: WebhookFields) => {
-      const {fields, url} = await getWebhookSettings(activeDoc, req, null, webhook);
+      const {fields, url, authorization} = await getWebhookSettings(activeDoc, req, null, webhook);
       if (!fields.eventTypes?.length) {
         throw new ApiError(`eventTypes must be a non-empty array`, 400);
       }
@@ -336,7 +336,7 @@ export class DocWorkerApi {
       }
 
       const unsubscribeKey = uuidv4();
-      const webhookSecret: WebHookSecret = {unsubscribeKey, url};
+      const webhookSecret: WebHookSecret = {unsubscribeKey, url, authorization};
       const secretValue = JSON.stringify(webhookSecret);
       const webhookId = (await this._dbManager.addSecret(secretValue, activeDoc.docName)).id;
 
@@ -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, watchedColIds, isReadyColumn, name} = webhook;
+      const {url, authorization, eventTypes, watchedColIds, isReadyColumn, name} = webhook;
       const tableId = await getRealTableId(req.params.tableId || webhook.tableId, {metaTables});
 
       const fields: Partial<SchemaTypes['_grist_Triggers']> = {};
@@ -454,6 +454,7 @@ export class DocWorkerApi {
       return {
         fields,
         url,
+        authorization,
       };
     }
 
@@ -926,16 +927,16 @@ export class DocWorkerApi {
 
         const docId = activeDoc.docName;
         const webhookId = req.params.webhookId;
-        const {fields, url} = await getWebhookSettings(activeDoc, req, webhookId, req.body);
+        const {fields, url, authorization} = await getWebhookSettings(activeDoc, req, webhookId, req.body);
         if (fields.enabled === false) {
           await activeDoc.triggers.clearSingleWebhookQueue(webhookId);
         }
 
         const triggerRowId = activeDoc.triggers.getWebhookTriggerRecord(webhookId).id;
 
-        // update url in homedb
-        if (url) {
-          await this._dbManager.updateWebhookUrl(webhookId, docId, url);
+        // update url and authorization header in homedb
+        if (url || authorization) {
+          await this._dbManager.updateWebhookUrlAndAuth({id: webhookId, docId, url, auth: authorization});
           activeDoc.triggers.webhookDeleted(webhookId); // clear cache
         }
 
diff --git a/app/server/lib/Triggers.ts b/app/server/lib/Triggers.ts
index c90ee548..e9d51484 100644
--- a/app/server/lib/Triggers.ts
+++ b/app/server/lib/Triggers.ts
@@ -72,6 +72,7 @@ type Trigger = MetaRowRecord<"_grist_Triggers">;
 export interface WebHookSecret {
   url: string;
   unsubscribeKey: string;
+  authorization?: string;
 }
 
 // Work to do after fetching values from the document
@@ -259,6 +260,7 @@ export class DocTriggers {
     const getTableId = docData.getMetaTable("_grist_Tables").getRowPropFunc("tableId");
     const getColId = docData.getMetaTable("_grist_Tables_column").getRowPropFunc("colId");
     const getUrl = async (id: string) => (await this._getWebHook(id))?.url ?? '';
+    const getAuthorization = async (id: string) => (await this._getWebHook(id))?.authorization ?? '';
     const getUnsubscribeKey = async (id: string) => (await this._getWebHook(id))?.unsubscribeKey ?? '';
     const resultTable: WebhookSummary[] = [];
 
@@ -271,6 +273,7 @@ export class DocTriggers {
       for (const act of webhookActions) {
         // Url, probably should be hidden for non-owners (but currently this API is owners only).
         const url = await getUrl(act.id);
+        const authorization = await getAuthorization(act.id);
         // Same story, should be hidden.
         const unsubscribeKey = await getUnsubscribeKey(act.id);
         if (!url || !unsubscribeKey) {
@@ -285,6 +288,7 @@ export class DocTriggers {
           fields: {
             // Url, probably should be hidden for non-owners (but currently this API is owners only).
             url,
+            authorization,
             unsubscribeKey,
             // Other fields used to register this webhook.
             eventTypes: decodeObject(t.eventTypes) as string[],
@@ -683,6 +687,7 @@ export class DocTriggers {
       const batch = _.takeWhile(this._webHookEventQueue.slice(0, 100), {id});
       const body = JSON.stringify(batch.map(e => e.payload));
       const url = await this._getWebHookUrl(id);
+      const authorization = (await this._getWebHook(id))?.authorization || "";
       if (this._loopAbort.signal.aborted) {
         continue;
       }
@@ -698,7 +703,8 @@ export class DocTriggers {
         this._activeDoc.logTelemetryEvent(null, 'sendingWebhooks', {
           limited: {numEvents: meta.numEvents},
         });
-        success = await this._sendWebhookWithRetries(id, url, body, batch.length, this._loopAbort.signal);
+        success = await this._sendWebhookWithRetries(
+          id, url, authorization, body, batch.length, this._loopAbort.signal);
         if (this._loopAbort.signal.aborted) {
           continue;
         }
@@ -770,7 +776,8 @@ export class DocTriggers {
     return this._drainingQueue ? Math.min(5, TRIGGER_MAX_ATTEMPTS) : TRIGGER_MAX_ATTEMPTS;
   }
 
-  private async _sendWebhookWithRetries(id: string, url: string, body: string, size: number, signal: AbortSignal) {
+  private async _sendWebhookWithRetries(
+    id: string, url: string, authorization: string, body: string, size: number, signal: AbortSignal) {
     const maxWait = 64;
     let wait = 1;
     for (let attempt = 0; attempt < this._maxWebhookAttempts; attempt++) {
@@ -786,6 +793,7 @@ export class DocTriggers {
           body,
           headers: {
             'Content-Type': 'application/json',
+            ...(authorization ? {'Authorization': authorization} : {}),
           },
           signal,
           agent: proxyAgent(new URL(url)),
diff --git a/static/locales/en.client.json b/static/locales/en.client.json
index 420e580e..76790bb1 100644
--- a/static/locales/en.client.json
+++ b/static/locales/en.client.json
@@ -1241,7 +1241,8 @@
         "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)"
+        "Filter for changes in these columns (semicolon-separated ids)": "Filter for changes in these columns (semicolon-separated ids)",
+        "Header Authorization": "Header Authorization"
     },
     "FormulaAssistant": {
         "Ask the bot.": "Ask the bot.",
diff --git a/test/nbrowser/WebhookPage.ts b/test/nbrowser/WebhookPage.ts
index 5db3e972..d53f4ebe 100644
--- a/test/nbrowser/WebhookPage.ts
+++ b/test/nbrowser/WebhookPage.ts
@@ -52,10 +52,11 @@ describe('WebhookPage', function () {
       'Name',
       'Memo',
       'Event Types',
-      'URL',
       'Table',
-      'Ready Column',
       'Filter for changes in these columns (semicolon-separated ids)',
+      'Ready Column',
+      'URL',
+      'Header Authorization',
       'Webhook Id',
       'Enabled',
       'Status',
@@ -81,7 +82,7 @@ describe('WebhookPage', function () {
     await gu.waitToPass(async () => {
       assert.equal(await getField(1, 'Webhook Id'), id);
     });
-    // Now other fields like name, memo and watchColIds are persisted.
+    // Now other fields like name, memo, watchColIds, and Header Auth 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');
@@ -115,6 +116,27 @@ describe('WebhookPage', function () {
     assert.lengthOf((await docApi.getRows('Table2')).A, 0);
   });
 
+  it('can create webhook with persistant header authorization', async function () {
+    // The webhook won't work because the header auth doesn't match the api key of the current test user.
+    await openWebhookPage();
+    await setField(1, 'Event Types', 'add\nupdate\n');
+    await setField(1, 'URL', `http://${host}/api/docs/${doc.id}/tables/Table2/records?flat=1`);
+    await setField(1, 'Table', 'Table1');
+    await gu.waitForServer();
+    await driver.navigate().refresh();
+    await waitForWebhookPage();
+    await setField(1, 'Header Authorization', 'Bearer 1234');
+    await gu.waitForServer();
+    await driver.navigate().refresh();
+    await waitForWebhookPage();
+    await gu.waitToPass(async () => {
+      assert.equal(await getField(1, 'Header Authorization'), 'Bearer 1234');
+    });
+    await gu.getDetailCell({col:'Header Authorization', rowNum: 1}).click();
+    await gu.enterCell(Key.DELETE, Key.ENTER);
+    await gu.waitForServer();
+  });
+
   it('can create two webhooks', async function () {
     await openWebhookPage();
     await setField(1, 'Event Types', 'add\nupdate\n');
diff --git a/test/server/lib/DocApi.ts b/test/server/lib/DocApi.ts
index 86b515d3..a12f1756 100644
--- a/test/server/lib/DocApi.ts
+++ b/test/server/lib/DocApi.ts
@@ -4625,6 +4625,7 @@ function testDocApi() {
             id: first.webhookId,
             fields: {
               url: `${serving.url}/200`,
+              authorization: '',
               unsubscribeKey: first.unsubscribeKey,
               eventTypes: ['add', 'update'],
               enabled: true,
@@ -4643,6 +4644,7 @@ function testDocApi() {
             id: second.webhookId,
             fields: {
               url: `${serving.url}/404`,
+              authorization: '',
               unsubscribeKey: second.unsubscribeKey,
               eventTypes: ['add', 'update'],
               enabled: true,
@@ -5010,6 +5012,7 @@ function testDocApi() {
 
             const expectedFields = {
               url: `${serving.url}/foo`,
+              authorization: '',
               eventTypes: ['add'],
               isReadyColumn: 'B',
               tableId: 'Table1',
@@ -5079,6 +5082,8 @@ function testDocApi() {
 
           await check({isReadyColumn: null}, 200);
           await check({isReadyColumn: "bar"}, 404, `Column not found "bar"`);
+
+          await check({authorization: 'Bearer fake-token'}, 200);
         });
 
       });

From 9c4814e7aa1f833033c6305d8d8fe6de6873afcb Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jaros=C5=82aw=20Sadzi=C5=84ski?=
 <jaroslaw.sadzinski@gmail.com>
Date: Thu, 4 Jul 2024 14:27:49 +0200
Subject: [PATCH 034/145] (core) Bundling save funciton in the field editor

Summary:
Some editors do some async work before saving the value (Ref column can add new
records). Those actions were send without bundling, so it wasn't possible to undo those
actions with togheter.

Test Plan: Added new test

Reviewers: georgegevoian

Reviewed By: georgegevoian

Differential Revision: https://phab.getgrist.com/D4285
---
 app/client/widgets/FieldEditor.ts | 97 ++++++++++++++++---------------
 test/nbrowser/ReferenceList.ts    | 35 +++++++++++
 2 files changed, 84 insertions(+), 48 deletions(-)

diff --git a/app/client/widgets/FieldEditor.ts b/app/client/widgets/FieldEditor.ts
index e473c154..23634d91 100644
--- a/app/client/widgets/FieldEditor.ts
+++ b/app/client/widgets/FieldEditor.ts
@@ -380,59 +380,60 @@ export class FieldEditor extends Disposable {
     if (!editor) { return false; }
     // Make sure the editor is save ready
     const saveIndex = this._cursor.rowIndex();
-    await editor.prepForSave();
-    if (this.isDisposed()) {
-      // We shouldn't normally get disposed here, but if we do, avoid confusing JS errors.
-      console.warn(t("Unable to finish saving edited cell"));  // tslint:disable-line:no-console
-      return false;
-    }
-
-    // Then save the value the appropriate way
-    // TODO: this isFormula value doesn't actually reflect if editing the formula, since
-    // editingFormula() is used for toggling column headers, and this is deferred to start of
-    // typing (a double-click or Enter) does not immediately set it. (This can cause a
-    // console.warn below, although harmless.)
-    const isFormula = this._field.editingFormula();
-    const col = this._field.column();
-    let waitPromise: Promise<unknown>|null = null;
-
-    if (isFormula) {
-      const formula = String(editor.getCellValue() ?? '');
-      // Bundle multiple changes so that we can undo them in one step.
-      if (isFormula !== col.isFormula.peek() || formula !== col.formula.peek()) {
-        waitPromise = this._gristDoc.docData.bundleActions(null, () => Promise.all([
-          col.updateColValues({isFormula, formula}),
-          // If we're saving a non-empty formula, then also add an empty record to the table
-          // so that the formula calculation is visible to the user.
-          (!this._detached.get() && this._editRow._isAddRow.peek() && formula !== "" ?
-            this._editRow.updateColValues({}) : undefined),
-        ]));
+    return await this._gristDoc.docData.bundleActions(null, async () => {
+      await editor.prepForSave();
+      if (this.isDisposed()) {
+        // We shouldn't normally get disposed here, but if we do, avoid confusing JS errors.
+        console.warn(t("Unable to finish saving edited cell"));  // tslint:disable-line:no-console
+        return false;
       }
-    } else {
-      const value = editor.getCellValue();
-      if (col.isRealFormula()) {
-        // tslint:disable-next-line:no-console
-        console.warn(t("It should be impossible to save a plain data value into a formula column"));
+      // Then save the value the appropriate way
+      // TODO: this isFormula value doesn't actually reflect if editing the formula, since
+      // editingFormula() is used for toggling column headers, and this is deferred to start of
+      // typing (a double-click or Enter) does not immediately set it. (This can cause a
+      // console.warn below, although harmless.)
+      const isFormula = this._field.editingFormula();
+      const col = this._field.column();
+      let waitPromise: Promise<unknown>|null = null;
+
+      if (isFormula) {
+        const formula = String(editor.getCellValue() ?? '');
+        // Bundle multiple changes so that we can undo them in one step.
+        if (isFormula !== col.isFormula.peek() || formula !== col.formula.peek()) {
+          waitPromise = Promise.all([
+            col.updateColValues({isFormula, formula}),
+            // If we're saving a non-empty formula, then also add an empty record to the table
+            // so that the formula calculation is visible to the user.
+            (!this._detached.get() && this._editRow._isAddRow.peek() && formula !== "" ?
+              this._editRow.updateColValues({}) : undefined),
+          ]);
+        }
       } else {
-        // This could still be an isFormula column if it's empty (isEmpty is true), but we don't
-        // need to toggle isFormula in that case, since the data engine takes care of that.
-        waitPromise = setAndSave(this._editRow, this._field, value);
+        const value = editor.getCellValue();
+        if (col.isRealFormula()) {
+          // tslint:disable-next-line:no-console
+          console.warn(t("It should be impossible to save a plain data value into a formula column"));
+        } else {
+          // This could still be an isFormula column if it's empty (isEmpty is true), but we don't
+          // need to toggle isFormula in that case, since the data engine takes care of that.
+          waitPromise = setAndSave(this._editRow, this._field, value);
+        }
       }
-    }
 
-    const event: FieldEditorStateEvent = {
-      position : this.cellPosition(),
-      wasModified : this._editorHasChanged,
-      currentState : this._editorHolder.get()?.editorState?.get(),
-      type : this._field.column.peek().pureType.peek()
-    };
-    this.saveEmitter.emit(event);
+      const event: FieldEditorStateEvent = {
+        position : this.cellPosition(),
+        wasModified : this._editorHasChanged,
+        currentState : this._editorHolder.get()?.editorState?.get(),
+        type : this._field.column.peek().pureType.peek()
+      };
+      this.saveEmitter.emit(event);
 
-    const cursor = this._cursor;
-    // Deactivate the editor. We are careful to avoid using `this` afterwards.
-    this.dispose();
-    await waitPromise;
-    return isFormula || (saveIndex !== cursor.rowIndex());
+      const cursor = this._cursor;
+      // Deactivate the editor. We are careful to avoid using `this` afterwards.
+      this.dispose();
+      await waitPromise;
+      return isFormula || (saveIndex !== cursor.rowIndex());
+    });
   }
 }
 
diff --git a/test/nbrowser/ReferenceList.ts b/test/nbrowser/ReferenceList.ts
index 7757b8e8..7f0d4a7d 100644
--- a/test/nbrowser/ReferenceList.ts
+++ b/test/nbrowser/ReferenceList.ts
@@ -13,6 +13,41 @@ describe('ReferenceList', function() {
   });
 
   describe('other', function() {
+    it('fix: changing ref list with a new referenced row was not bundled', async function() {
+      // When user added a new referenced row through the RefList editor, the UI sent two separate
+      // actions. One for adding the new row, and another for updating the RefList column.
+      await session.tempNewDoc(cleanup);
+      await gu.sendActions([
+        ['ModifyColumn', 'Table1', 'B', {type: 'RefList:Table1'}],
+        ['AddRecord', 'Table1', null, {A: 'a'}],
+      ]);
+      await gu.openColumnPanel();
+      await gu.getCell('B', 1).doClick();
+      await gu.setRefShowColumn('A');
+
+      await gu.getCell('B', 1).click();
+      await gu.sendKeys(Key.ENTER, 'b');
+      await gu.waitToPass(async () => {
+        await driver.findWait('.test-ref-editor-new-item', 100).click();
+      });
+      await gu.sendKeys(Key.ENTER);
+      await gu.waitForServer();
+
+      // Check the data - use waitToPass helper, as previously it might have failed
+      // as 2 separate actions were sent.
+      await gu.waitToPass(async () => {
+        assert.deepEqual(await gu.getVisibleGridCells('A', [1, 2]), ['a', 'b']);
+        assert.deepEqual(await gu.getVisibleGridCells('B', [1, 2]), ['b', '']);
+        assert.equal(await gu.getGridRowCount(), 3);
+      });
+
+      // Now press undo once, and check that the new row is removed and the RefList is updated.
+      await gu.undo();
+      assert.deepEqual(await gu.getVisibleGridCells('A', [1]), ['a']);
+      assert.deepEqual(await gu.getVisibleGridCells('B', [1]), ['']);
+      assert.equal(await gu.getGridRowCount(), 2);
+    });
+
     it('fix: doesnt break when table is renamed', async function() {
       // There was a bug in this scenario:
       // 1. Create a Ref column that targets itself

From 786ba6b31e9af4ec2d87156d299d2f9042e18973 Mon Sep 17 00:00:00 2001
From: Florent <florent.git@zeteo.me>
Date: Fri, 5 Jul 2024 16:02:39 +0200
Subject: [PATCH 035/145] Move HomeDBManager to gen-server/lib/homedb (#1076)

---
 app/gen-server/ApiServer.ts                      | 2 +-
 app/gen-server/lib/Activations.ts                | 2 +-
 app/gen-server/lib/DocApiForwarder.ts            | 2 +-
 app/gen-server/lib/Doom.ts                       | 2 +-
 app/gen-server/lib/Housekeeper.ts                | 2 +-
 app/gen-server/lib/Usage.ts                      | 2 +-
 app/gen-server/lib/{ => homedb}/HomeDBManager.ts | 0
 app/gen-server/lib/homedb/UsersManager.ts        | 2 +-
 app/server/companion.ts                          | 2 +-
 app/server/lib/AppEndpoint.ts                    | 2 +-
 app/server/lib/Authorizer.ts                     | 2 +-
 app/server/lib/Client.ts                         | 2 +-
 app/server/lib/DocApi.ts                         | 2 +-
 app/server/lib/DocManager.ts                     | 2 +-
 app/server/lib/DocWorker.ts                      | 2 +-
 app/server/lib/FlexServer.ts                     | 2 +-
 app/server/lib/GranularAccess.ts                 | 2 +-
 app/server/lib/GristServer.ts                    | 2 +-
 app/server/lib/HostedMetadataManager.ts          | 2 +-
 app/server/lib/HostedStorageManager.ts           | 2 +-
 app/server/lib/ICreate.ts                        | 2 +-
 app/server/lib/InstallAdmin.ts                   | 2 +-
 app/server/lib/Telemetry.ts                      | 2 +-
 app/server/lib/TestLogin.ts                      | 2 +-
 app/server/lib/extractOrg.ts                     | 2 +-
 app/server/lib/requestUtils.ts                   | 2 +-
 app/server/lib/sendAppPage.ts                    | 2 +-
 stubs/app/server/server.ts                       | 2 +-
 test/gen-server/ApiServer.ts                     | 2 +-
 test/gen-server/ApiServerAccess.ts               | 2 +-
 test/gen-server/ApiServerBugs.ts                 | 2 +-
 test/gen-server/AuthCaching.ts                   | 2 +-
 test/gen-server/apiUtils.ts                      | 2 +-
 test/gen-server/migrations.ts                    | 2 +-
 test/gen-server/seed.ts                          | 2 +-
 test/gen-server/testUtils.ts                     | 2 +-
 test/nbrowser/homeUtil.ts                        | 2 +-
 test/nbrowser/testServer.ts                      | 2 +-
 test/server/lib/Authorizer.ts                    | 2 +-
 test/server/lib/HostedStorageManager.ts          | 2 +-
 test/testUtils.ts                                | 2 +-
 41 files changed, 40 insertions(+), 40 deletions(-)
 rename app/gen-server/lib/{ => homedb}/HomeDBManager.ts (100%)

diff --git a/app/gen-server/ApiServer.ts b/app/gen-server/ApiServer.ts
index 915012ad..b927d62f 100644
--- a/app/gen-server/ApiServer.ts
+++ b/app/gen-server/ApiServer.ts
@@ -9,7 +9,7 @@ import {FullUser} from 'app/common/LoginSessionAPI';
 import {BasicRole} from 'app/common/roles';
 import {OrganizationProperties, PermissionDelta} from 'app/common/UserAPI';
 import {User} from 'app/gen-server/entity/User';
-import {BillingOptions, HomeDBManager, QueryResult, Scope} from 'app/gen-server/lib/HomeDBManager';
+import {BillingOptions, HomeDBManager, QueryResult, Scope} from 'app/gen-server/lib/homedb/HomeDBManager';
 import {getAuthorizedUserId, getUserId, getUserProfiles, RequestWithLogin} from 'app/server/lib/Authorizer';
 import {getSessionUser, linkOrgWithEmail} from 'app/server/lib/BrowserSession';
 import {expressWrap} from 'app/server/lib/expressWrap';
diff --git a/app/gen-server/lib/Activations.ts b/app/gen-server/lib/Activations.ts
index b089efbe..2648c98b 100644
--- a/app/gen-server/lib/Activations.ts
+++ b/app/gen-server/lib/Activations.ts
@@ -1,6 +1,6 @@
 import { makeId } from 'app/server/lib/idUtils';
 import { Activation } from 'app/gen-server/entity/Activation';
-import { HomeDBManager } from 'app/gen-server/lib/HomeDBManager';
+import { HomeDBManager } from 'app/gen-server/lib/homedb/HomeDBManager';
 
 /**
  * Manage activations. Not much to do currently, there is at most one
diff --git a/app/gen-server/lib/DocApiForwarder.ts b/app/gen-server/lib/DocApiForwarder.ts
index 3545a63a..ed58e03b 100644
--- a/app/gen-server/lib/DocApiForwarder.ts
+++ b/app/gen-server/lib/DocApiForwarder.ts
@@ -5,7 +5,7 @@ import {AbortController} from 'node-abort-controller';
 import { ApiError } from 'app/common/ApiError';
 import { SHARE_KEY_PREFIX } from 'app/common/gristUrls';
 import { removeTrailingSlash } from 'app/common/gutil';
-import { HomeDBManager } from "app/gen-server/lib/HomeDBManager";
+import { HomeDBManager } from "app/gen-server/lib/homedb/HomeDBManager";
 import { assertAccess, getOrSetDocAuth, getTransitiveHeaders, RequestWithLogin } from 'app/server/lib/Authorizer';
 import { IDocWorkerMap } from "app/server/lib/DocWorkerMap";
 import { expressWrap } from "app/server/lib/expressWrap";
diff --git a/app/gen-server/lib/Doom.ts b/app/gen-server/lib/Doom.ts
index cbce2587..1d6bc0d6 100644
--- a/app/gen-server/lib/Doom.ts
+++ b/app/gen-server/lib/Doom.ts
@@ -1,7 +1,7 @@
 import { ApiError } from 'app/common/ApiError';
 import { FullUser } from 'app/common/UserAPI';
 import { Organization } from 'app/gen-server/entity/Organization';
-import { HomeDBManager, Scope } from 'app/gen-server/lib/HomeDBManager';
+import { HomeDBManager, Scope } from 'app/gen-server/lib/homedb/HomeDBManager';
 import { INotifier } from 'app/server/lib/INotifier';
 import { scrubUserFromOrg } from 'app/gen-server/lib/scrubUserFromOrg';
 import { GristLoginSystem } from 'app/server/lib/GristServer';
diff --git a/app/gen-server/lib/Housekeeper.ts b/app/gen-server/lib/Housekeeper.ts
index c40379fe..c3012bec 100644
--- a/app/gen-server/lib/Housekeeper.ts
+++ b/app/gen-server/lib/Housekeeper.ts
@@ -7,7 +7,7 @@ import { Document } from 'app/gen-server/entity/Document';
 import { Organization } from 'app/gen-server/entity/Organization';
 import { Product } from 'app/gen-server/entity/Product';
 import { Workspace } from 'app/gen-server/entity/Workspace';
-import { HomeDBManager, Scope } from 'app/gen-server/lib/HomeDBManager';
+import { HomeDBManager, Scope } from 'app/gen-server/lib/homedb/HomeDBManager';
 import { fromNow } from 'app/gen-server/sqlUtils';
 import { getAuthorizedUserId } from 'app/server/lib/Authorizer';
 import { expressWrap } from 'app/server/lib/expressWrap';
diff --git a/app/gen-server/lib/Usage.ts b/app/gen-server/lib/Usage.ts
index 865fa2b1..9082bdcc 100644
--- a/app/gen-server/lib/Usage.ts
+++ b/app/gen-server/lib/Usage.ts
@@ -1,7 +1,7 @@
 import {Document} from 'app/gen-server/entity/Document';
 import {Organization} from 'app/gen-server/entity/Organization';
 import {User} from 'app/gen-server/entity/User';
-import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
+import {HomeDBManager} from 'app/gen-server/lib/homedb/HomeDBManager';
 import log from 'app/server/lib/log';
 
 // Frequency of logging usage information.  Not something we need
diff --git a/app/gen-server/lib/HomeDBManager.ts b/app/gen-server/lib/homedb/HomeDBManager.ts
similarity index 100%
rename from app/gen-server/lib/HomeDBManager.ts
rename to app/gen-server/lib/homedb/HomeDBManager.ts
diff --git a/app/gen-server/lib/homedb/UsersManager.ts b/app/gen-server/lib/homedb/UsersManager.ts
index 168665f3..e070273a 100644
--- a/app/gen-server/lib/homedb/UsersManager.ts
+++ b/app/gen-server/lib/homedb/UsersManager.ts
@@ -17,7 +17,7 @@ import { Group } from 'app/gen-server/entity/Group';
 import { Login } from 'app/gen-server/entity/Login';
 import { User } from 'app/gen-server/entity/User';
 import { appSettings } from 'app/server/lib/AppSettings';
-import { HomeDBManager, PermissionDeltaAnalysis, Scope } from 'app/gen-server/lib/HomeDBManager';
+import { HomeDBManager, PermissionDeltaAnalysis, Scope } from 'app/gen-server/lib/homedb/HomeDBManager';
 import {
   AvailableUsers, GetUserOptions, NonGuestGroup, QueryResult, Resource, RunInTransaction, UserProfileChange
 } from 'app/gen-server/lib/homedb/Interfaces';
diff --git a/app/server/companion.ts b/app/server/companion.ts
index f28475c8..bad8092c 100644
--- a/app/server/companion.ts
+++ b/app/server/companion.ts
@@ -1,7 +1,7 @@
 import { Level, TelemetryContracts } from 'app/common/Telemetry';
 import { version } from 'app/common/version';
 import { synchronizeProducts } from 'app/gen-server/entity/Product';
-import { HomeDBManager } from 'app/gen-server/lib/HomeDBManager';
+import { HomeDBManager } from 'app/gen-server/lib/homedb/HomeDBManager';
 import { applyPatch } from 'app/gen-server/lib/TypeORMPatches';
 import { getMigrations, getOrCreateConnection, getTypeORMSettings,
          undoLastMigration, updateDb } from 'app/server/lib/dbUtils';
diff --git a/app/server/lib/AppEndpoint.ts b/app/server/lib/AppEndpoint.ts
index b327a4e1..8147bfcf 100644
--- a/app/server/lib/AppEndpoint.ts
+++ b/app/server/lib/AppEndpoint.ts
@@ -11,7 +11,7 @@ import {LocalPlugin} from "app/common/plugin";
 import {TELEMETRY_TEMPLATE_SIGNUP_COOKIE_NAME} from 'app/common/Telemetry';
 import {Document as APIDocument, PublicDocWorkerUrlInfo} from 'app/common/UserAPI';
 import {Document} from "app/gen-server/entity/Document";
-import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
+import {HomeDBManager} from 'app/gen-server/lib/homedb/HomeDBManager';
 import {assertAccess, getTransitiveHeaders, getUserId, isAnonymousUser,
         RequestWithLogin} from 'app/server/lib/Authorizer';
 import {DocStatus, IDocWorkerMap} from 'app/server/lib/DocWorkerMap';
diff --git a/app/server/lib/Authorizer.ts b/app/server/lib/Authorizer.ts
index 6f9a6741..0386a6d3 100644
--- a/app/server/lib/Authorizer.ts
+++ b/app/server/lib/Authorizer.ts
@@ -7,7 +7,7 @@ import {canEdit, canView, getWeakestRole, Role} from 'app/common/roles';
 import {UserOptions} from 'app/common/UserAPI';
 import {Document} from 'app/gen-server/entity/Document';
 import {User} from 'app/gen-server/entity/User';
-import {DocAuthKey, DocAuthResult, HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
+import {DocAuthKey, DocAuthResult, HomeDBManager} from 'app/gen-server/lib/homedb/HomeDBManager';
 import {forceSessionChange, getSessionProfiles, getSessionUser, getSignInStatus, linkOrgWithEmail, SessionObj,
         SessionUserObj, SignInStatus} from 'app/server/lib/BrowserSession';
 import {RequestWithOrg} from 'app/server/lib/extractOrg';
diff --git a/app/server/lib/Client.ts b/app/server/lib/Client.ts
index 0364ca36..ce2a9b0b 100644
--- a/app/server/lib/Client.ts
+++ b/app/server/lib/Client.ts
@@ -8,7 +8,7 @@ import {TelemetryMetadata} from 'app/common/Telemetry';
 import {ANONYMOUS_USER_EMAIL} from 'app/common/UserAPI';
 import {normalizeEmail} from 'app/common/emails';
 import {User} from 'app/gen-server/entity/User';
-import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
+import {HomeDBManager} from 'app/gen-server/lib/homedb/HomeDBManager';
 import {ActiveDoc} from 'app/server/lib/ActiveDoc';
 import {Authorizer} from 'app/server/lib/Authorizer';
 import {ScopedSession} from 'app/server/lib/BrowserSession';
diff --git a/app/server/lib/DocApi.ts b/app/server/lib/DocApi.ts
index 297cafff..f7d0a946 100644
--- a/app/server/lib/DocApi.ts
+++ b/app/server/lib/DocApi.ts
@@ -30,7 +30,7 @@ import {TelemetryMetadataByLevel} from "app/common/Telemetry";
 import {WebhookFields} from "app/common/Triggers";
 import TriggersTI from 'app/common/Triggers-ti';
 import {DocReplacementOptions, DocState, DocStateComparison, DocStates, NEW_DOCUMENT_CODE} from 'app/common/UserAPI';
-import {HomeDBManager, makeDocAuthResult} from 'app/gen-server/lib/HomeDBManager';
+import {HomeDBManager, makeDocAuthResult} from 'app/gen-server/lib/homedb/HomeDBManager';
 import * as Types from "app/plugin/DocApiTypes";
 import DocApiTypesTI from "app/plugin/DocApiTypes-ti";
 import {GristObjCode} from "app/plugin/GristData";
diff --git a/app/server/lib/DocManager.ts b/app/server/lib/DocManager.ts
index 437559f8..f342ab9a 100644
--- a/app/server/lib/DocManager.ts
+++ b/app/server/lib/DocManager.ts
@@ -15,7 +15,7 @@ import {Invite} from 'app/common/sharing';
 import {tbind} from 'app/common/tbind';
 import {TelemetryMetadataByLevel} from 'app/common/Telemetry';
 import {NEW_DOCUMENT_CODE} from 'app/common/UserAPI';
-import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
+import {HomeDBManager} from 'app/gen-server/lib/homedb/HomeDBManager';
 import {assertAccess, Authorizer, DocAuthorizer, DummyAuthorizer, isSingleUserMode,
         RequestWithLogin} from 'app/server/lib/Authorizer';
 import {Client} from 'app/server/lib/Client';
diff --git a/app/server/lib/DocWorker.ts b/app/server/lib/DocWorker.ts
index 7bb8d8e6..b8f0d608 100644
--- a/app/server/lib/DocWorker.ts
+++ b/app/server/lib/DocWorker.ts
@@ -3,7 +3,7 @@
  * In hosted environment, this comprises the functionality of the DocWorker instance type.
  */
 import {isAffirmative} from 'app/common/gutil';
-import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
+import {HomeDBManager} from 'app/gen-server/lib/homedb/HomeDBManager';
 import {ActionHistoryImpl} from 'app/server/lib/ActionHistoryImpl';
 import {assertAccess, getOrSetDocAuth, RequestWithLogin} from 'app/server/lib/Authorizer';
 import {Client} from 'app/server/lib/Client';
diff --git a/app/server/lib/FlexServer.ts b/app/server/lib/FlexServer.ts
index 812e1d21..a3568ecb 100644
--- a/app/server/lib/FlexServer.ts
+++ b/app/server/lib/FlexServer.ts
@@ -20,7 +20,7 @@ import {Activations} from 'app/gen-server/lib/Activations';
 import {DocApiForwarder} from 'app/gen-server/lib/DocApiForwarder';
 import {getDocWorkerMap} from 'app/gen-server/lib/DocWorkerMap';
 import {Doom} from 'app/gen-server/lib/Doom';
-import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
+import {HomeDBManager} from 'app/gen-server/lib/homedb/HomeDBManager';
 import {Housekeeper} from 'app/gen-server/lib/Housekeeper';
 import {Usage} from 'app/gen-server/lib/Usage';
 import {AccessTokens, IAccessTokens} from 'app/server/lib/AccessTokens';
diff --git a/app/server/lib/GranularAccess.ts b/app/server/lib/GranularAccess.ts
index 69fcf2ee..08626869 100644
--- a/app/server/lib/GranularAccess.ts
+++ b/app/server/lib/GranularAccess.ts
@@ -35,7 +35,7 @@ import { EmptyRecordView, InfoView, RecordView } from 'app/common/RecordView';
 import { canEdit, canView, isValidRole, Role } from 'app/common/roles';
 import { User } from 'app/common/User';
 import { FullUser, UserAccessData } from 'app/common/UserAPI';
-import { HomeDBManager } from 'app/gen-server/lib/HomeDBManager';
+import { HomeDBManager } from 'app/gen-server/lib/homedb/HomeDBManager';
 import { GristObjCode } from 'app/plugin/GristData';
 import { DocClients } from 'app/server/lib/DocClients';
 import { getDocSessionAccess, getDocSessionAltSessionId, getDocSessionShare,
diff --git a/app/server/lib/GristServer.ts b/app/server/lib/GristServer.ts
index 1e926395..265535d7 100644
--- a/app/server/lib/GristServer.ts
+++ b/app/server/lib/GristServer.ts
@@ -8,7 +8,7 @@ import { Organization } from 'app/gen-server/entity/Organization';
 import { User } from 'app/gen-server/entity/User';
 import { Workspace } from 'app/gen-server/entity/Workspace';
 import { Activations } from 'app/gen-server/lib/Activations';
-import { HomeDBManager } from 'app/gen-server/lib/HomeDBManager';
+import { HomeDBManager } from 'app/gen-server/lib/homedb/HomeDBManager';
 import { IAccessTokens } from 'app/server/lib/AccessTokens';
 import { RequestWithLogin } from 'app/server/lib/Authorizer';
 import { Comm } from 'app/server/lib/Comm';
diff --git a/app/server/lib/HostedMetadataManager.ts b/app/server/lib/HostedMetadataManager.ts
index bce0a055..f49a545b 100644
--- a/app/server/lib/HostedMetadataManager.ts
+++ b/app/server/lib/HostedMetadataManager.ts
@@ -1,4 +1,4 @@
-import {DocumentMetadata, HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
+import {DocumentMetadata, HomeDBManager} from 'app/gen-server/lib/homedb/HomeDBManager';
 import log from 'app/server/lib/log';
 
 /**
diff --git a/app/server/lib/HostedStorageManager.ts b/app/server/lib/HostedStorageManager.ts
index 9be39f85..fa73beb6 100644
--- a/app/server/lib/HostedStorageManager.ts
+++ b/app/server/lib/HostedStorageManager.ts
@@ -8,7 +8,7 @@ import {DocumentUsage} from 'app/common/DocUsage';
 import {buildUrlId, parseUrlId} from 'app/common/gristUrls';
 import {KeyedOps} from 'app/common/KeyedOps';
 import {DocReplacementOptions, NEW_DOCUMENT_CODE} from 'app/common/UserAPI';
-import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
+import {HomeDBManager} from 'app/gen-server/lib/homedb/HomeDBManager';
 import {checksumFile} from 'app/server/lib/checksumFile';
 import {DocSnapshotInventory, DocSnapshotPruner} from 'app/server/lib/DocSnapshots';
 import {IDocWorkerMap} from 'app/server/lib/DocWorkerMap';
diff --git a/app/server/lib/ICreate.ts b/app/server/lib/ICreate.ts
index 3ca48d0c..4b4d66ee 100644
--- a/app/server/lib/ICreate.ts
+++ b/app/server/lib/ICreate.ts
@@ -1,7 +1,7 @@
 import {GristDeploymentType} from 'app/common/gristUrls';
 import {getThemeBackgroundSnippet} from 'app/common/Themes';
 import {Document} from 'app/gen-server/entity/Document';
-import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
+import {HomeDBManager} from 'app/gen-server/lib/homedb/HomeDBManager';
 import {ExternalStorage} from 'app/server/lib/ExternalStorage';
 import {createDummyTelemetry, GristServer} from 'app/server/lib/GristServer';
 import {IBilling} from 'app/server/lib/IBilling';
diff --git a/app/server/lib/InstallAdmin.ts b/app/server/lib/InstallAdmin.ts
index 0a00bfa1..f7fdba0d 100644
--- a/app/server/lib/InstallAdmin.ts
+++ b/app/server/lib/InstallAdmin.ts
@@ -1,5 +1,5 @@
 import {ApiError} from 'app/common/ApiError';
-import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
+import {HomeDBManager} from 'app/gen-server/lib/homedb/HomeDBManager';
 import {appSettings} from 'app/server/lib/AppSettings';
 import {getUser, RequestWithLogin} from 'app/server/lib/Authorizer';
 import {User} from 'app/gen-server/entity/User';
diff --git a/app/server/lib/Telemetry.ts b/app/server/lib/Telemetry.ts
index 6d341600..a08381c1 100644
--- a/app/server/lib/Telemetry.ts
+++ b/app/server/lib/Telemetry.ts
@@ -17,7 +17,7 @@ import {
 import {TelemetryPrefsWithSources} from 'app/common/InstallAPI';
 import {Activation} from 'app/gen-server/entity/Activation';
 import {Activations} from 'app/gen-server/lib/Activations';
-import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
+import {HomeDBManager} from 'app/gen-server/lib/homedb/HomeDBManager';
 import {RequestWithLogin} from 'app/server/lib/Authorizer';
 import {getDocSessionUser, OptDocSession} from 'app/server/lib/DocSession';
 import {expressWrap} from 'app/server/lib/expressWrap';
diff --git a/app/server/lib/TestLogin.ts b/app/server/lib/TestLogin.ts
index 2cd0d5ac..12ef87a3 100644
--- a/app/server/lib/TestLogin.ts
+++ b/app/server/lib/TestLogin.ts
@@ -1,4 +1,4 @@
-import {SUPPORT_EMAIL} from 'app/gen-server/lib/HomeDBManager';
+import {SUPPORT_EMAIL} from 'app/gen-server/lib/homedb/HomeDBManager';
 import {GristLoginSystem, GristServer} from 'app/server/lib/GristServer';
 import {Request} from 'express';
 
diff --git a/app/server/lib/extractOrg.ts b/app/server/lib/extractOrg.ts
index 787fa1b8..524aa97e 100644
--- a/app/server/lib/extractOrg.ts
+++ b/app/server/lib/extractOrg.ts
@@ -3,7 +3,7 @@ import { mapGetOrSet, MapWithTTL } from 'app/common/AsyncCreate';
 import { extractOrgParts, getHostType, getKnownOrg } from 'app/common/gristUrls';
 import { isAffirmative } from 'app/common/gutil';
 import { Organization } from 'app/gen-server/entity/Organization';
-import { HomeDBManager } from 'app/gen-server/lib/HomeDBManager';
+import { HomeDBManager } from 'app/gen-server/lib/homedb/HomeDBManager';
 import { GristServer } from 'app/server/lib/GristServer';
 import { getOriginUrl } from 'app/server/lib/requestUtils';
 import { NextFunction, Request, RequestHandler, Response } from 'express';
diff --git a/app/server/lib/requestUtils.ts b/app/server/lib/requestUtils.ts
index de0326d4..7f693966 100644
--- a/app/server/lib/requestUtils.ts
+++ b/app/server/lib/requestUtils.ts
@@ -1,7 +1,7 @@
 import {ApiError} from 'app/common/ApiError';
 import { DEFAULT_HOME_SUBDOMAIN, isOrgInPathOnly, parseSubdomain, sanitizePathTail } from 'app/common/gristUrls';
 import * as gutil from 'app/common/gutil';
-import {DocScope, QueryResult, Scope} from 'app/gen-server/lib/HomeDBManager';
+import {DocScope, QueryResult, Scope} from 'app/gen-server/lib/homedb/HomeDBManager';
 import {getUserId, RequestWithLogin} from 'app/server/lib/Authorizer';
 import {RequestWithOrg} from 'app/server/lib/extractOrg';
 import {RequestWithGrist} from 'app/server/lib/GristServer';
diff --git a/app/server/lib/sendAppPage.ts b/app/server/lib/sendAppPage.ts
index ec53f2be..3fce3c38 100644
--- a/app/server/lib/sendAppPage.ts
+++ b/app/server/lib/sendAppPage.ts
@@ -12,7 +12,7 @@ import {isAffirmative} from 'app/common/gutil';
 import {getTagManagerSnippet} from 'app/common/tagManager';
 import {Document} from 'app/common/UserAPI';
 import {AttachedCustomWidgets, IAttachedCustomWidget} from "app/common/widgetTypes";
-import {SUPPORT_EMAIL} from 'app/gen-server/lib/HomeDBManager';
+import {SUPPORT_EMAIL} from 'app/gen-server/lib/homedb/HomeDBManager';
 import {isAnonymousUser, isSingleUserMode, RequestWithLogin} from 'app/server/lib/Authorizer';
 import {RequestWithOrg} from 'app/server/lib/extractOrg';
 import {GristServer} from 'app/server/lib/GristServer';
diff --git a/stubs/app/server/server.ts b/stubs/app/server/server.ts
index e8761cef..6fbffdf5 100644
--- a/stubs/app/server/server.ts
+++ b/stubs/app/server/server.ts
@@ -6,7 +6,7 @@
 
 import {commonUrls} from 'app/common/gristUrls';
 import {isAffirmative} from 'app/common/gutil';
-import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
+import {HomeDBManager} from 'app/gen-server/lib/homedb/HomeDBManager';
 import {fixSiteProducts} from 'app/gen-server/lib/Housekeeper';
 
 const debugging = isAffirmative(process.env.DEBUG) || isAffirmative(process.env.VERBOSE);
diff --git a/test/gen-server/ApiServer.ts b/test/gen-server/ApiServer.ts
index 9546a51c..39aba8c3 100644
--- a/test/gen-server/ApiServer.ts
+++ b/test/gen-server/ApiServer.ts
@@ -8,7 +8,7 @@ import {createEmptyOrgUsageSummary, OrgUsageSummary} from 'app/common/DocUsage';
 import {Document, Workspace} from 'app/common/UserAPI';
 import {Organization} from 'app/gen-server/entity/Organization';
 import {Product} from 'app/gen-server/entity/Product';
-import {HomeDBManager, UserChange} from 'app/gen-server/lib/HomeDBManager';
+import {HomeDBManager, UserChange} from 'app/gen-server/lib/homedb/HomeDBManager';
 import {TestServer} from 'test/gen-server/apiUtils';
 import {TEAM_FREE_PLAN} from 'app/common/Features';
 
diff --git a/test/gen-server/ApiServerAccess.ts b/test/gen-server/ApiServerAccess.ts
index 2742fed5..33abe90b 100644
--- a/test/gen-server/ApiServerAccess.ts
+++ b/test/gen-server/ApiServerAccess.ts
@@ -4,7 +4,7 @@ import {Deps} from 'app/gen-server/ApiServer';
 import {Organization} from 'app/gen-server/entity/Organization';
 import {Product} from 'app/gen-server/entity/Product';
 import {User} from 'app/gen-server/entity/User';
-import {HomeDBManager, UserChange} from 'app/gen-server/lib/HomeDBManager';
+import {HomeDBManager, UserChange} from 'app/gen-server/lib/homedb/HomeDBManager';
 import {SendGridConfig, SendGridMail} from 'app/gen-server/lib/NotifierTypes';
 import axios, {AxiosResponse} from 'axios';
 import {delay} from 'bluebird';
diff --git a/test/gen-server/ApiServerBugs.ts b/test/gen-server/ApiServerBugs.ts
index 020b09c7..f8ef2112 100644
--- a/test/gen-server/ApiServerBugs.ts
+++ b/test/gen-server/ApiServerBugs.ts
@@ -4,7 +4,7 @@ import * as chai from 'chai';
 import {configForUser} from 'test/gen-server/testUtils';
 import * as testUtils from 'test/server/testUtils';
 
-import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
+import {HomeDBManager} from 'app/gen-server/lib/homedb/HomeDBManager';
 
 import {TestServer} from 'test/gen-server/apiUtils';
 
diff --git a/test/gen-server/AuthCaching.ts b/test/gen-server/AuthCaching.ts
index 5789d1ce..d28f7382 100644
--- a/test/gen-server/AuthCaching.ts
+++ b/test/gen-server/AuthCaching.ts
@@ -1,5 +1,5 @@
 import {delay} from 'app/common/delay';
-import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
+import {HomeDBManager} from 'app/gen-server/lib/homedb/HomeDBManager';
 import {FlexServer} from 'app/server/lib/FlexServer';
 import log from 'app/server/lib/log';
 import {main as mergedServerMain} from 'app/server/mergedServerMain';
diff --git a/test/gen-server/apiUtils.ts b/test/gen-server/apiUtils.ts
index 5737a496..2dbaf73c 100644
--- a/test/gen-server/apiUtils.ts
+++ b/test/gen-server/apiUtils.ts
@@ -11,7 +11,7 @@ import {User} from 'app/gen-server/entity/User';
 import {Workspace} from 'app/gen-server/entity/Workspace';
 import {SessionUserObj} from 'app/server/lib/BrowserSession';
 import {getDocWorkerMap} from 'app/gen-server/lib/DocWorkerMap';
-import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
+import {HomeDBManager} from 'app/gen-server/lib/homedb/HomeDBManager';
 import * as docUtils from 'app/server/lib/docUtils';
 import {FlexServer, FlexServerOptions} from 'app/server/lib/FlexServer';
 import {main as mergedServerMain, ServerType} from 'app/server/mergedServerMain';
diff --git a/test/gen-server/migrations.ts b/test/gen-server/migrations.ts
index e6a45b98..f3cc32df 100644
--- a/test/gen-server/migrations.ts
+++ b/test/gen-server/migrations.ts
@@ -1,7 +1,7 @@
 import {QueryRunner} from "typeorm";
 import * as roles from "app/common/roles";
 import {Organization} from 'app/gen-server/entity/Organization';
-import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
+import {HomeDBManager} from 'app/gen-server/lib/homedb/HomeDBManager';
 import {Permissions} from 'app/gen-server/lib/Permissions';
 import {assert} from 'chai';
 import {addSeedData, createInitialDb, removeConnection, setUpDB} from 'test/gen-server/seed';
diff --git a/test/gen-server/seed.ts b/test/gen-server/seed.ts
index 5a6addc9..274283ce 100644
--- a/test/gen-server/seed.ts
+++ b/test/gen-server/seed.ts
@@ -40,7 +40,7 @@ import {Organization} from "app/gen-server/entity/Organization";
 import {Product, PRODUCTS, synchronizeProducts, teamFreeFeatures} from "app/gen-server/entity/Product";
 import {User} from "app/gen-server/entity/User";
 import {Workspace} from "app/gen-server/entity/Workspace";
-import {EXAMPLE_WORKSPACE_NAME} from 'app/gen-server/lib/HomeDBManager';
+import {EXAMPLE_WORKSPACE_NAME} from 'app/gen-server/lib/homedb/HomeDBManager';
 import {Permissions} from 'app/gen-server/lib/Permissions';
 import {getOrCreateConnection, runMigrations, undoLastMigration, updateDb} from 'app/server/lib/dbUtils';
 import {FlexServer} from 'app/server/lib/FlexServer';
diff --git a/test/gen-server/testUtils.ts b/test/gen-server/testUtils.ts
index ef14ad00..3f7707ab 100644
--- a/test/gen-server/testUtils.ts
+++ b/test/gen-server/testUtils.ts
@@ -2,7 +2,7 @@ import {GristLoadConfig} from 'app/common/gristUrls';
 import {BillingAccount} from 'app/gen-server/entity/BillingAccount';
 import {Organization} from 'app/gen-server/entity/Organization';
 import {Product} from 'app/gen-server/entity/Product';
-import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
+import {HomeDBManager} from 'app/gen-server/lib/homedb/HomeDBManager';
 import {INotifier} from 'app/server/lib/INotifier';
 import {AxiosRequestConfig} from "axios";
 import {delay} from 'bluebird';
diff --git a/test/nbrowser/homeUtil.ts b/test/nbrowser/homeUtil.ts
index f19bfc58..b5edccd4 100644
--- a/test/nbrowser/homeUtil.ts
+++ b/test/nbrowser/homeUtil.ts
@@ -13,7 +13,7 @@ import {normalizeEmail} from 'app/common/emails';
 import {UserProfile} from 'app/common/LoginSessionAPI';
 import {BehavioralPrompt, UserPrefs, WelcomePopup} from 'app/common/Prefs';
 import {DocWorkerAPI, UserAPI, UserAPIImpl} from 'app/common/UserAPI';
-import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
+import {HomeDBManager} from 'app/gen-server/lib/homedb/HomeDBManager';
 import {TestingHooksClient} from 'app/server/lib/TestingHooks';
 import EventEmitter = require('events');
 
diff --git a/test/nbrowser/testServer.ts b/test/nbrowser/testServer.ts
index 417ad8dc..4928a253 100644
--- a/test/nbrowser/testServer.ts
+++ b/test/nbrowser/testServer.ts
@@ -11,7 +11,7 @@
  * into a file whose path is printed when server starts.
  */
 import {encodeUrl, IGristUrlState, parseSubdomain} from 'app/common/gristUrls';
-import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
+import {HomeDBManager} from 'app/gen-server/lib/homedb/HomeDBManager';
 import log from 'app/server/lib/log';
 import {getAppRoot} from 'app/server/lib/places';
 import {makeGristConfig} from 'app/server/lib/sendAppPage';
diff --git a/test/server/lib/Authorizer.ts b/test/server/lib/Authorizer.ts
index d8d6389d..191e3920 100644
--- a/test/server/lib/Authorizer.ts
+++ b/test/server/lib/Authorizer.ts
@@ -1,5 +1,5 @@
 import {parseUrlId} from 'app/common/gristUrls';
-import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
+import {HomeDBManager} from 'app/gen-server/lib/homedb/HomeDBManager';
 import {DocManager} from 'app/server/lib/DocManager';
 import {FlexServer} from 'app/server/lib/FlexServer';
 import axios from 'axios';
diff --git a/test/server/lib/HostedStorageManager.ts b/test/server/lib/HostedStorageManager.ts
index ae8224c3..2fee78c2 100644
--- a/test/server/lib/HostedStorageManager.ts
+++ b/test/server/lib/HostedStorageManager.ts
@@ -2,7 +2,7 @@ import {ErrorOrValue, freezeError, mapGetOrSet, MapWithTTL} from 'app/common/Asy
 import {ObjMetadata, ObjSnapshot, ObjSnapshotWithMetadata} from 'app/common/DocSnapshot';
 import {SCHEMA_VERSION} from 'app/common/schema';
 import {DocWorkerMap} from 'app/gen-server/lib/DocWorkerMap';
-import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
+import {HomeDBManager} from 'app/gen-server/lib/homedb/HomeDBManager';
 import {ActiveDoc} from 'app/server/lib/ActiveDoc';
 import {create} from 'app/server/lib/create';
 import {DocManager} from 'app/server/lib/DocManager';
diff --git a/test/testUtils.ts b/test/testUtils.ts
index bda21d13..e8affb5a 100644
--- a/test/testUtils.ts
+++ b/test/testUtils.ts
@@ -1,4 +1,4 @@
-import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
+import {HomeDBManager} from 'app/gen-server/lib/homedb/HomeDBManager';
 
 export async function getDatabase(typeormDb?: string): Promise<HomeDBManager> {
   const origTypeormDB = process.env.TYPEORM_DATABASE;

From 6908807236996c419f55a13a18328b9f13c8ba60 Mon Sep 17 00:00:00 2001
From: Spoffy <4805393+Spoffy@users.noreply.github.com>
Date: Mon, 8 Jul 2024 15:40:45 +0100
Subject: [PATCH 036/145] Extracts config.json into its own module (#1061)

This adds a config file that's loaded very early on during startup.

It enables us to save/load settings from within Grist's admin panel, that affect the startup of the FlexServer.

The config file loading:
- Is type-safe,
- Validates the config file on startup
- Provides a path to upgrade to future versions.

It should be extensible from other versions of Grist (such as desktop), by overriding `getGlobalConfig` in stubs.

----

Some minor refactors needed to occur to make this possible. This includes:
- Extracting config loading into its own module (out of FlexServer).
- Cleaning up the `loadConfig` function in FlexServer into `loadLoginSystem` (which is what its main purpose was before).
---
 app/server/lib/FlexServer.ts               |  42 +++---
 app/server/lib/GristServer.ts              |   5 +-
 app/server/lib/config.ts                   | 143 +++++++++++++++++++++
 app/server/lib/configCore.ts               |  28 ++++
 app/server/lib/configCoreFileFormats-ti.ts |  23 ++++
 app/server/lib/configCoreFileFormats.ts    |  53 ++++++++
 app/server/lib/places.ts                   |   7 +
 app/server/mergedServerMain.ts             |   7 +-
 stubs/app/server/lib/globalConfig.ts       |  19 +++
 test/gen-server/seed.ts                    |   2 +-
 test/server/lib/Authorizer.ts              |   4 +-
 test/server/lib/config.ts                  | 107 +++++++++++++++
 test/server/lib/configCore.ts              |  48 +++++++
 test/server/lib/configCoreFileFormats.ts   |  29 +++++
 14 files changed, 484 insertions(+), 33 deletions(-)
 create mode 100644 app/server/lib/config.ts
 create mode 100644 app/server/lib/configCore.ts
 create mode 100644 app/server/lib/configCoreFileFormats-ti.ts
 create mode 100644 app/server/lib/configCoreFileFormats.ts
 create mode 100644 stubs/app/server/lib/globalConfig.ts
 create mode 100644 test/server/lib/config.ts
 create mode 100644 test/server/lib/configCore.ts
 create mode 100644 test/server/lib/configCoreFileFormats.ts

diff --git a/app/server/lib/FlexServer.ts b/app/server/lib/FlexServer.ts
index a3568ecb..8004e4e2 100644
--- a/app/server/lib/FlexServer.ts
+++ b/app/server/lib/FlexServer.ts
@@ -54,7 +54,7 @@ import {InstallAdmin} from 'app/server/lib/InstallAdmin';
 import log from 'app/server/lib/log';
 import {getLoginSystem} from 'app/server/lib/logins';
 import {IPermitStore} from 'app/server/lib/Permit';
-import {getAppPathTo, getAppRoot, getUnpackedAppRoot} from 'app/server/lib/places';
+import {getAppPathTo, getAppRoot, getInstanceRoot, getUnpackedAppRoot} from 'app/server/lib/places';
 import {addPluginEndpoints, limitToPlugins} from 'app/server/lib/PluginEndpoint';
 import {PluginManager} from 'app/server/lib/PluginManager';
 import * as ProcessMonitor from 'app/server/lib/ProcessMonitor';
@@ -87,6 +87,7 @@ import {AddressInfo} from 'net';
 import fetch from 'node-fetch';
 import * as path from 'path';
 import * as serveStatic from "serve-static";
+import {IGristCoreConfig} from "./configCore";
 
 // Health checks are a little noisy in the logs, so we don't show them all.
 // We show the first N health checks:
@@ -105,6 +106,9 @@ export interface FlexServerOptions {
   baseDomain?: string;
   // Base URL for plugins, if permitted. Defaults to APP_UNTRUSTED_URL.
   pluginUrl?: string;
+
+  // Global grist config options
+  settings?: IGristCoreConfig;
 }
 
 const noop: express.RequestHandler = (req, res, next) => next();
@@ -122,7 +126,7 @@ export class FlexServer implements GristServer {
   public housekeeper: Housekeeper;
   public server: http.Server;
   public httpsServer?: https.Server;
-  public settings?: Readonly<Record<string, unknown>>;
+  public settings?: IGristCoreConfig;
   public worker: DocWorkerInfo;
   public electronServerMethods: ElectronServerMethods;
   public readonly docsRoot: string;
@@ -186,6 +190,7 @@ export class FlexServer implements GristServer {
 
   constructor(public port: number, public name: string = 'flexServer',
               public readonly options: FlexServerOptions = {}) {
+    this.settings = options.settings;
     this.app = express();
     this.app.set('port', port);
 
@@ -662,7 +667,7 @@ export class FlexServer implements GristServer {
 
   public get instanceRoot() {
     if (!this._instanceRoot) {
-      this._instanceRoot = path.resolve(process.env.GRIST_INST_DIR || this.appRoot);
+      this._instanceRoot = getInstanceRoot();
       this.info.push(['instanceRoot', this._instanceRoot]);
     }
     return this._instanceRoot;
@@ -774,7 +779,7 @@ export class FlexServer implements GristServer {
   // Set up the main express middleware used.  For a single user setup, without logins,
   // all this middleware is currently a no-op.
   public addAccessMiddleware() {
-    if (this._check('middleware', 'map', 'config', isSingleUserMode() ? null : 'hosts')) { return; }
+    if (this._check('middleware', 'map', 'loginMiddleware', isSingleUserMode() ? null : 'hosts')) { return; }
 
     if (!isSingleUserMode()) {
       const skipSession = appSettings.section('login').flag('skipSession').readBool({
@@ -938,7 +943,7 @@ export class FlexServer implements GristServer {
   }
 
   public addSessions() {
-    if (this._check('sessions', 'config')) { return; }
+    if (this._check('sessions', 'loginMiddleware')) { return; }
     this.addTagChecker();
     this.addOrg();
 
@@ -1135,25 +1140,8 @@ export class FlexServer implements GristServer {
     });
   }
 
-  /**
-   * Load user config file from standard location (if present).
-   *
-   * Note that the user config file doesn't do anything today, but may be useful in
-   * the future for configuring things that don't fit well into environment variables.
-   *
-   * TODO: Revisit this, and update `GristServer.settings` type to match the expected shape
-   * of config.json. (ts-interface-checker could be useful here for runtime validation.)
-   */
-  public async loadConfig() {
-    if (this._check('config')) { return; }
-    const settingsPath = path.join(this.instanceRoot, 'config.json');
-    if (await fse.pathExists(settingsPath)) {
-      log.info(`Loading config from ${settingsPath}`);
-      this.settings = JSON.parse(await fse.readFile(settingsPath, 'utf8'));
-    } else {
-      log.info(`Loading empty config because ${settingsPath} missing`);
-      this.settings = {};
-    }
+  public async addLoginMiddleware() {
+    if (this._check('loginMiddleware')) { return; }
 
     // TODO: We could include a third mock provider of login/logout URLs for better tests. Or we
     // could create a mock SAML identity provider for testing this using the SAML flow.
@@ -1169,9 +1157,9 @@ export class FlexServer implements GristServer {
   }
 
   public addComm() {
-    if (this._check('comm', 'start', 'homedb', 'config')) { return; }
+    if (this._check('comm', 'start', 'homedb', 'loginMiddleware')) { return; }
     this._comm = new Comm(this.server, {
-      settings: this.settings,
+      settings: {},
       sessions: this._sessions,
       hosts: this._hosts,
       loginMiddleware: this._loginMiddleware,
@@ -1311,7 +1299,7 @@ export class FlexServer implements GristServer {
       null : 'homedb', 'api-mw', 'map', 'telemetry');
     // add handlers for cleanup, if we are in charge of the doc manager.
     if (!this._docManager) { this.addCleanup(); }
-    await this.loadConfig();
+    await this.addLoginMiddleware();
     this.addComm();
 
     await this.create.configure?.();
diff --git a/app/server/lib/GristServer.ts b/app/server/lib/GristServer.ts
index 265535d7..8d31de7e 100644
--- a/app/server/lib/GristServer.ts
+++ b/app/server/lib/GristServer.ts
@@ -25,6 +25,7 @@ import { Sessions } from 'app/server/lib/Sessions';
 import { ITelemetry } from 'app/server/lib/Telemetry';
 import * as express from 'express';
 import { IncomingMessage } from 'http';
+import { IGristCoreConfig, loadGristCoreConfig } from "./configCore";
 
 /**
  * Basic information about a Grist server.  Accessible in many
@@ -32,7 +33,7 @@ import { IncomingMessage } from 'http';
  */
 export interface GristServer {
   readonly create: ICreate;
-  settings?: Readonly<Record<string, unknown>>;
+  settings?: IGristCoreConfig;
   getHost(): string;
   getHomeUrl(req: express.Request, relPath?: string): string;
   getHomeInternalUrl(relPath?: string): string;
@@ -126,7 +127,7 @@ export interface DocTemplate {
 export function createDummyGristServer(): GristServer {
   return {
     create,
-    settings: {},
+    settings: loadGristCoreConfig(),
     getHost() { return 'localhost:4242'; },
     getHomeUrl() { return 'http://localhost:4242'; },
     getHomeInternalUrl() { return 'http://localhost:4242'; },
diff --git a/app/server/lib/config.ts b/app/server/lib/config.ts
new file mode 100644
index 00000000..067198f7
--- /dev/null
+++ b/app/server/lib/config.ts
@@ -0,0 +1,143 @@
+import * as fse from "fs-extra";
+
+// Export dependencies for stubbing in tests.
+export const Deps = {
+  readFile: fse.readFile,
+  writeFile: fse.writeFile,
+  pathExists: fse.pathExists,
+};
+
+/**
+ * Readonly config value - no write access.
+ */
+export interface IReadableConfigValue<T> {
+  get(): T;
+}
+
+/**
+ * Writeable config value. Write behaviour is asynchronous and defined by the implementation.
+ */
+export interface IWritableConfigValue<T> extends IReadableConfigValue<T> {
+  set(value: T): Promise<void>;
+}
+
+type FileContentsValidator<T> = (value: any) => T | null;
+
+export class MissingConfigFileError extends Error {
+  public name: string = "MissingConfigFileError";
+
+  constructor(message: string) {
+    super(message);
+  }
+}
+
+export class ConfigValidationError extends Error {
+  public name: string = "ConfigValidationError";
+
+  constructor(message: string) {
+    super(message);
+  }
+}
+
+export interface ConfigAccessors<ValueType> {
+  get: () => ValueType,
+  set?: (value: ValueType) => Promise<void>
+}
+
+/**
+ * Provides type safe access to an underlying JSON file.
+ *
+ * Multiple FileConfigs for the same file shouldn't be used, as they risk going out of sync.
+ */
+export class FileConfig<FileContents> {
+  /**
+   * Creates a new type-safe FileConfig, by loading and checking the contents of the file with `validator`.
+   * @param configPath - Path to load.
+   * @param validator - Validates the contents are in the correct format, and converts to the correct type.
+   *  Should throw an error or return null if not valid.
+   */
+  public static async create<CreateConfigFileContents>(
+    configPath: string,
+    validator: FileContentsValidator<CreateConfigFileContents>
+  ): Promise<FileConfig<CreateConfigFileContents>> {
+    // Start with empty object, as it can be upgraded to a full config.
+    let rawFileContents: any = {};
+
+    if (await Deps.pathExists(configPath)) {
+      rawFileContents = JSON.parse(await Deps.readFile(configPath, 'utf8'));
+    }
+
+    let fileContents = null;
+
+    try {
+      fileContents = validator(rawFileContents);
+    } catch (error) {
+      const configError =
+        new ConfigValidationError(`Config at ${configPath} failed validation: ${error.message}`);
+      configError.cause = error;
+      throw configError;
+    }
+
+    if (!fileContents) {
+      throw new ConfigValidationError(`Config at ${configPath} failed validation - check the format?`);
+    }
+
+    return new FileConfig<CreateConfigFileContents>(configPath, fileContents);
+  }
+
+  constructor(private _filePath: string, private _rawConfig: FileContents) {
+  }
+
+  public get<Key extends keyof FileContents>(key: Key): FileContents[Key] {
+    return this._rawConfig[key];
+  }
+
+  public async set<Key extends keyof FileContents>(key: Key, value: FileContents[Key]) {
+    this._rawConfig[key] = value;
+    await this.persistToDisk();
+  }
+
+  public async persistToDisk(): Promise<void> {
+    await Deps.writeFile(this._filePath, JSON.stringify(this._rawConfig, null, 2));
+  }
+}
+
+/**
+ * Creates a function for creating accessors for a given key.
+ * Propagates undefined values, so if no file config is available, accessors are undefined.
+ * @param fileConfig - Config to load/save values to.
+ */
+export function fileConfigAccessorFactory<FileContents>(
+  fileConfig?: FileConfig<FileContents>
+): <Key extends keyof FileContents>(key: Key) => ConfigAccessors<FileContents[Key]> | undefined
+{
+  if (!fileConfig) { return (key) => undefined; }
+  return (key) => ({
+    get: () => fileConfig.get(key),
+    set: (value) => fileConfig.set(key, value)
+  });
+}
+
+/**
+ * Creates a config value optionally backed by persistent storage.
+ * Can be used as an in-memory value without persistent storage.
+ * @param defaultValue - Value to use if no persistent value is available.
+ * @param persistence - Accessors for saving/loading persistent value.
+ */
+export function createConfigValue<ValueType>(
+  defaultValue: ValueType,
+  persistence?: ConfigAccessors<ValueType> | ConfigAccessors<ValueType | undefined>,
+): IWritableConfigValue<ValueType> {
+  let inMemoryValue = (persistence && persistence.get());
+  return {
+    get(): ValueType {
+      return inMemoryValue ?? defaultValue;
+    },
+    async set(value: ValueType) {
+      if (persistence && persistence.set) {
+        await persistence.set(value);
+      }
+      inMemoryValue = value;
+    }
+  };
+}
diff --git a/app/server/lib/configCore.ts b/app/server/lib/configCore.ts
new file mode 100644
index 00000000..884e2cf7
--- /dev/null
+++ b/app/server/lib/configCore.ts
@@ -0,0 +1,28 @@
+import {
+  createConfigValue,
+  FileConfig,
+  fileConfigAccessorFactory,
+  IWritableConfigValue
+} from "./config";
+import { convertToCoreFileContents, IGristCoreConfigFileLatest } from "./configCoreFileFormats";
+
+export type Edition = "core" | "enterprise";
+
+/**
+ * Config options for Grist Core.
+ */
+export interface IGristCoreConfig {
+  edition: IWritableConfigValue<Edition>;
+}
+
+export async function loadGristCoreConfigFile(configPath?: string): Promise<IGristCoreConfig> {
+  const fileConfig = configPath ? await FileConfig.create(configPath, convertToCoreFileContents) : undefined;
+  return loadGristCoreConfig(fileConfig);
+}
+
+export function loadGristCoreConfig(fileConfig?: FileConfig<IGristCoreConfigFileLatest>): IGristCoreConfig {
+  const fileConfigValue = fileConfigAccessorFactory(fileConfig);
+  return {
+    edition: createConfigValue<Edition>("core", fileConfigValue("edition"))
+  };
+}
diff --git a/app/server/lib/configCoreFileFormats-ti.ts b/app/server/lib/configCoreFileFormats-ti.ts
new file mode 100644
index 00000000..7bb39740
--- /dev/null
+++ b/app/server/lib/configCoreFileFormats-ti.ts
@@ -0,0 +1,23 @@
+/**
+ * This module was automatically generated by `ts-interface-builder`
+ */
+import * as t from "ts-interface-checker";
+// tslint:disable:object-literal-key-quotes
+
+export const IGristCoreConfigFileLatest = t.name("IGristCoreConfigFileV1");
+
+export const IGristCoreConfigFileV1 = t.iface([], {
+  "version": t.lit("1"),
+  "edition": t.opt(t.union(t.lit("core"), t.lit("enterprise"))),
+});
+
+export const IGristCoreConfigFileV0 = t.iface([], {
+  "version": "undefined",
+});
+
+const exportedTypeSuite: t.ITypeSuite = {
+  IGristCoreConfigFileLatest,
+  IGristCoreConfigFileV1,
+  IGristCoreConfigFileV0,
+};
+export default exportedTypeSuite;
diff --git a/app/server/lib/configCoreFileFormats.ts b/app/server/lib/configCoreFileFormats.ts
new file mode 100644
index 00000000..711b91cc
--- /dev/null
+++ b/app/server/lib/configCoreFileFormats.ts
@@ -0,0 +1,53 @@
+import configCoreTI from './configCoreFileFormats-ti';
+import { CheckerT, createCheckers } from "ts-interface-checker";
+
+/**
+ * Latest core config file format
+ */
+export type IGristCoreConfigFileLatest = IGristCoreConfigFileV1;
+
+/**
+ * Format of config files on disk - V1
+ */
+export interface IGristCoreConfigFileV1 {
+  version: "1"
+  edition?: "core" | "enterprise"
+}
+
+/**
+ * Format of config files on disk - V0
+ */
+export interface IGristCoreConfigFileV0 {
+  version: undefined;
+}
+
+export const checkers = createCheckers(configCoreTI) as
+  {
+    IGristCoreConfigFileV0: CheckerT<IGristCoreConfigFileV0>,
+    IGristCoreConfigFileV1: CheckerT<IGristCoreConfigFileV1>,
+    IGristCoreConfigFileLatest: CheckerT<IGristCoreConfigFileLatest>,
+  };
+
+function upgradeV0toV1(config: IGristCoreConfigFileV0): IGristCoreConfigFileV1 {
+  return {
+    ...config,
+    version: "1",
+  };
+}
+
+export function convertToCoreFileContents(input: any): IGristCoreConfigFileLatest | null {
+  if (!(input instanceof Object)) {
+    return null;
+  }
+
+  let configObject = { ...input };
+
+  if (checkers.IGristCoreConfigFileV0.test(configObject)) {
+    configObject = upgradeV0toV1(configObject);
+  }
+
+  // This will throw an exception if the config object is still not in the correct format.
+  checkers.IGristCoreConfigFileLatest.check(configObject);
+
+  return configObject;
+}
diff --git a/app/server/lib/places.ts b/app/server/lib/places.ts
index 9567db24..a4d619b1 100644
--- a/app/server/lib/places.ts
+++ b/app/server/lib/places.ts
@@ -63,3 +63,10 @@ export function getAppRootFor(appRoot: string, subdirectory: string): string {
 export function getAppPathTo(appRoot: string, subdirectory: string): string {
   return path.resolve(getAppRootFor(appRoot, subdirectory), subdirectory);
 }
+
+/**
+ * Returns the instance root. Defaults to appRoot, unless overridden by GRIST_INST_DIR.
+ */
+export function getInstanceRoot() {
+  return path.resolve(process.env.GRIST_INST_DIR || getAppRoot());
+}
diff --git a/app/server/mergedServerMain.ts b/app/server/mergedServerMain.ts
index 987343f6..f4e4a4a6 100644
--- a/app/server/mergedServerMain.ts
+++ b/app/server/mergedServerMain.ts
@@ -8,6 +8,7 @@
 import {FlexServer, FlexServerOptions} from 'app/server/lib/FlexServer';
 import {GristLoginSystem} from 'app/server/lib/GristServer';
 import log from 'app/server/lib/log';
+import {getGlobalConfig} from "app/server/lib/globalConfig";
 
 // Allowed server types. We'll start one or a combination based on the value of GRIST_SERVERS
 // environment variable.
@@ -70,6 +71,8 @@ export async function main(port: number, serverTypes: ServerType[],
   const includeStatic = serverTypes.includes("static");
   const includeApp = serverTypes.includes("app");
 
+  options.settings ??= await getGlobalConfig();
+
   const server = new FlexServer(port, `server(${serverTypes.join(",")})`, options);
 
   // We need to know early on whether we will be serving plugins or not.
@@ -94,7 +97,7 @@ export async function main(port: number, serverTypes: ServerType[],
 
   if (options.logToConsole !== false) { server.addLogging(); }
   if (options.externalStorage === false) { server.disableExternalStorage(); }
-  await server.loadConfig();
+  await server.addLoginMiddleware();
 
   if (includeDocs) {
     // It is important that /dw and /v prefixes are accepted (if present) by health check
@@ -195,12 +198,14 @@ export async function main(port: number, serverTypes: ServerType[],
 
 export async function startMain() {
   try {
+
     const serverTypes = parseServerTypes(process.env.GRIST_SERVERS);
 
     // No defaults for a port, since this server can serve very different purposes.
     if (!process.env.GRIST_PORT) {
       throw new Error("GRIST_PORT must be specified");
     }
+
     const port = parseInt(process.env.GRIST_PORT, 10);
 
     const server = await main(port, serverTypes);
diff --git a/stubs/app/server/lib/globalConfig.ts b/stubs/app/server/lib/globalConfig.ts
new file mode 100644
index 00000000..0c62d855
--- /dev/null
+++ b/stubs/app/server/lib/globalConfig.ts
@@ -0,0 +1,19 @@
+import path from "path";
+import { getInstanceRoot } from "app/server/lib/places";
+import { IGristCoreConfig, loadGristCoreConfigFile } from "app/server/lib/configCore";
+import log from "app/server/lib/log";
+
+const globalConfigPath: string = path.join(getInstanceRoot(), 'config.json');
+let cachedGlobalConfig: IGristCoreConfig | undefined = undefined;
+
+/**
+ * Retrieves the cached grist config, or loads it from the default global path.
+ */
+export async function getGlobalConfig(): Promise<IGristCoreConfig> {
+  if (!cachedGlobalConfig) {
+    log.info(`Loading config file from ${globalConfigPath}`);
+    cachedGlobalConfig = await loadGristCoreConfigFile(globalConfigPath);
+  }
+
+  return cachedGlobalConfig;
+}
diff --git a/test/gen-server/seed.ts b/test/gen-server/seed.ts
index 274283ce..d935211e 100644
--- a/test/gen-server/seed.ts
+++ b/test/gen-server/seed.ts
@@ -605,7 +605,7 @@ export async function createServer(port: number, initDb = createInitialDb): Prom
   await flexServer.start();
   await flexServer.initHomeDBManager();
   flexServer.addDocWorkerMap();
-  await flexServer.loadConfig();
+  await flexServer.addLoginMiddleware();
   flexServer.addHosts();
   flexServer.addAccessMiddleware();
   flexServer.addApiMiddleware();
diff --git a/test/server/lib/Authorizer.ts b/test/server/lib/Authorizer.ts
index 191e3920..c4e4fe99 100644
--- a/test/server/lib/Authorizer.ts
+++ b/test/server/lib/Authorizer.ts
@@ -17,13 +17,13 @@ let server: FlexServer;
 let dbManager: HomeDBManager;
 
 async function activateServer(home: FlexServer, docManager: DocManager) {
-  await home.loadConfig();
+  await home.addLoginMiddleware();
   await home.initHomeDBManager();
   home.addHosts();
   home.addDocWorkerMap();
   home.addAccessMiddleware();
   dbManager = home.getHomeDBManager();
-  await home.loadConfig();
+  await home.addLoginMiddleware();
   home.addSessions();
   home.addHealthCheck();
   docManager.testSetHomeDbManager(dbManager);
diff --git a/test/server/lib/config.ts b/test/server/lib/config.ts
new file mode 100644
index 00000000..711e75ec
--- /dev/null
+++ b/test/server/lib/config.ts
@@ -0,0 +1,107 @@
+import { assert } from 'chai';
+import * as sinon from 'sinon';
+import { ConfigAccessors, createConfigValue, Deps, FileConfig } from "app/server/lib/config";
+
+interface TestFileContents {
+  myNum?: number
+  myStr?: string
+}
+
+const testFileContentsExample: TestFileContents = {
+  myNum: 1,
+  myStr: "myStr",
+};
+
+const testFileContentsJSON = JSON.stringify(testFileContentsExample);
+
+describe('FileConfig', () => {
+  const useFakeConfigFile = (contents: string) => {
+    const fakeFile = { contents };
+    sinon.replace(Deps, 'pathExists', sinon.fake.resolves(true));
+    sinon.replace(Deps, 'readFile', sinon.fake((path, encoding: string) => Promise.resolve(fakeFile.contents)) as any);
+    sinon.replace(Deps, 'writeFile', sinon.fake((path, newContents) => {
+      fakeFile.contents = newContents;
+      return Promise.resolve();
+    }));
+
+    return fakeFile;
+  };
+
+  afterEach(() => {
+    sinon.restore();
+  });
+
+  it('throws an error from create if the validator does not return a value', async () => {
+    useFakeConfigFile(testFileContentsJSON);
+    const validator = () => null;
+    await assert.isRejected(FileConfig.create<TestFileContents>("anypath.json", validator));
+  });
+
+  it('persists changes when values are assigned', async () => {
+    const fakeFile = useFakeConfigFile(testFileContentsJSON);
+    // Don't validate - this is guaranteed to be valid above.
+    const validator = (input: any) => input as TestFileContents;
+    const fileConfig = await FileConfig.create("anypath.json", validator);
+    await fileConfig.set("myNum", 999);
+
+    assert.equal(fileConfig.get("myNum"), 999);
+    assert.equal(JSON.parse(fakeFile.contents).myNum, 999);
+  });
+
+  // Avoid removing extra properties from the file, in case another edition of grist is doing something.
+  it('does not remove extra values from the file', async () => {
+    const configWithExtraProperties = {
+      ...testFileContentsExample,
+      someProperty: "isPresent",
+    };
+
+    const fakeFile = useFakeConfigFile(JSON.stringify(configWithExtraProperties));
+    // It's entirely possible the validator can damage the extra properties, but that's not in scope for this test.
+    const validator = (input: any) => input as TestFileContents;
+    const fileConfig = await FileConfig.create("anypath.json", validator);
+    // Triggering a write to the file
+    await fileConfig.set("myNum", 999);
+    await fileConfig.set("myStr", "Something");
+
+    const newContents = JSON.parse(fakeFile.contents);
+    assert.equal(newContents.myNum, 999);
+    assert.equal(newContents.myStr, "Something");
+    assert.equal(newContents.someProperty, "isPresent");
+  });
+});
+
+describe('createConfigValue', () => {
+  const makeInMemoryAccessors = <T>(initialValue: T): ConfigAccessors<T> => {
+    let value: T = initialValue;
+    return {
+      get: () => value,
+      set: async (newValue: T) => { value = newValue; },
+    };
+  };
+
+  it('works without persistence', async () => {
+    const configValue = createConfigValue(1);
+    assert.equal(configValue.get(), 1);
+    await configValue.set(2);
+    assert.equal(configValue.get(), 2);
+  });
+
+  it('writes to persistence when saved', async () => {
+    const accessors = makeInMemoryAccessors(1);
+    const configValue = createConfigValue(1, accessors);
+    assert.equal(accessors.get(), 1);
+    await configValue.set(2);
+    assert.equal(accessors.get(), 2);
+  });
+
+  it('initialises with the persistent value if available', async () => {
+    const accessors = makeInMemoryAccessors(22);
+    const configValue = createConfigValue(1, accessors);
+    assert.equal(configValue.get(), 22);
+
+    const accessorsWithUndefinedValue = makeInMemoryAccessors<number | undefined>(undefined);
+    const configValueWithDefault = createConfigValue(333, accessorsWithUndefinedValue);
+    assert.equal(configValueWithDefault.get(), 333);
+  });
+});
+
diff --git a/test/server/lib/configCore.ts b/test/server/lib/configCore.ts
new file mode 100644
index 00000000..3d82ec68
--- /dev/null
+++ b/test/server/lib/configCore.ts
@@ -0,0 +1,48 @@
+import * as sinon from 'sinon';
+import { assert } from 'chai';
+import { IGristCoreConfig, loadGristCoreConfig, loadGristCoreConfigFile } from "app/server/lib/configCore";
+import { createConfigValue, Deps, IWritableConfigValue } from "app/server/lib/config";
+
+describe('loadGristCoreConfig', () => {
+  afterEach(() => {
+    sinon.restore();
+  });
+
+  it('can be used with an in-memory store if no file config is provided', async () => {
+    const config = loadGristCoreConfig();
+    await config.edition.set("enterprise");
+    assert.equal(config.edition.get(), "enterprise");
+  });
+
+  it('will function correctly when no config file is present', async () => {
+    sinon.replace(Deps, 'pathExists', sinon.fake.resolves(false));
+    sinon.replace(Deps, 'readFile', sinon.fake.resolves(""));
+    const writeFileFake = sinon.fake.resolves(undefined);
+    sinon.replace(Deps, 'writeFile', writeFileFake);
+
+    const config = await loadGristCoreConfigFile("doesntmatter.json");
+    assert.exists(config.edition.get());
+
+    await config.edition.set("enterprise");
+    // Make sure that the change was written back to the file.
+    assert.isTrue(writeFileFake.calledOnce);
+  });
+
+  it('can be extended', async () => {
+    // Extend the core config
+    type NewConfig = IGristCoreConfig & {
+      newThing: IWritableConfigValue<number>
+    };
+
+    const coreConfig = loadGristCoreConfig();
+
+    const newConfig: NewConfig = {
+      ...coreConfig,
+      newThing: createConfigValue(3)
+    };
+
+    // Ensure that it's backwards compatible.
+    const gristConfig: IGristCoreConfig = newConfig;
+    return gristConfig;
+  });
+});
diff --git a/test/server/lib/configCoreFileFormats.ts b/test/server/lib/configCoreFileFormats.ts
new file mode 100644
index 00000000..cf05c8ad
--- /dev/null
+++ b/test/server/lib/configCoreFileFormats.ts
@@ -0,0 +1,29 @@
+import { assert } from 'chai';
+import { convertToCoreFileContents, IGristCoreConfigFileLatest } from "app/server/lib/configCoreFileFormats";
+
+describe('convertToCoreFileContents', () => {
+  it('fails with a malformed config', async () => {
+    const badConfig = {
+      version: "This is a random version number that will never exist",
+    };
+
+    assert.throws(() => convertToCoreFileContents(badConfig));
+  });
+
+  // This is necessary to handle users who don't have a config file yet.
+  it('will upgrade an empty object to a valid config', () => {
+    const validConfig = convertToCoreFileContents({});
+    assert.exists(validConfig?.version);
+  });
+
+  it('will validate the latest config file format', () => {
+    const validRawObject: IGristCoreConfigFileLatest = {
+      version: "1",
+      edition: "enterprise",
+    };
+
+    const validConfig = convertToCoreFileContents(validRawObject);
+    assert.exists(validConfig?.version);
+    assert.exists(validConfig?.edition);
+  });
+});

From 90a9291e0dbbbb5661623e4f79c56327617d0e9c Mon Sep 17 00:00:00 2001
From: Spoffy <4805393+Spoffy@users.noreply.github.com>
Date: Mon, 8 Jul 2024 18:23:36 +0100
Subject: [PATCH 037/145] Configures Enterprise Edition image to automatically
 start with enterprise features enabled (#1084)

* Makes EE be separately built and pushed
* Adds parameterisation to docker_latest.yml to simplify testing
---
 .github/workflows/docker_latest.yml | 76 +++++++++++++++++++++++------
 buildtools/.grist-ee-version        |  2 +-
 2 files changed, 63 insertions(+), 15 deletions(-)

diff --git a/.github/workflows/docker_latest.yml b/.github/workflows/docker_latest.yml
index 01abfd84..b3adb7ef 100644
--- a/.github/workflows/docker_latest.yml
+++ b/.github/workflows/docker_latest.yml
@@ -11,11 +11,36 @@ on:
     - cron:  '41 5 * * *'
   workflow_dispatch:
     inputs:
-      latest_branch:
+      branch:
         description: "Branch from which to create the latest Docker image (default: latest_candidate)"
         type: string
         required: true
-        default_value: latest_candidate
+        default: latest_candidate
+      disable_tests:
+        description: "Should the tests be skipped?"
+        type: boolean
+        required: True
+        default: False
+      platforms:
+        description: "Platforms to build"
+        type: choice
+        required: True
+        options:
+          - linux/amd64
+          - linux/arm64/v8
+          - linux/amd64,linux/arm64/v8
+        default: linux/amd64,linux/arm64/v8
+      tag:
+        description: "Tag for the resulting images"
+        type: string
+        required: True
+        default: 'experimental'
+
+env:
+  BRANCH: ${{ inputs.branch || 'latest_candidate' }}
+  PLATFORMS: ${{ inputs.platforms || 'linux/amd64,linux/arm64/v8' }}
+  TAG: ${{ inputs.tag || 'experimental' }}
+  DOCKER_HUB_OWNER: ${{ vars.DOCKER_HUB_OWNER || github.repository_owner }}
 
 jobs:
   push_to_registry:
@@ -32,21 +57,23 @@ jobs:
             repo: "grist-core"
           - name: "grist"
             repo: "grist-ee"
-          # For now, we build it twice, with `grist-ee` being a
-          # backwards-compatible synonym for `grist`.
-          - name: "grist-ee"
-            repo: "grist-ee"
     steps:
+      - name: Build settings
+        run: |
+          echo "Branch: $BRANCH"
+          echo "Platforms: $PLATFORMS"
+          echo "Docker Hub Owner: $DOCKER_HUB_OWNER"
+          echo "Tag: $TAG"
+
       - name: Check out the repo
-        uses: actions/checkout@v2
+        uses: actions/checkout@v4
         with:
-          ref: ${{ inputs.latest_branch }}
+          ref: ${{ env.BRANCH }}
 
       - name: Check out the ext/ directory
         if: matrix.image.name != 'grist-oss'
         run: buildtools/checkout-ext-directory.sh ${{ matrix.image.repo }}
 
-
       - name: Set up QEMU
         uses: docker/setup-qemu-action@v1
 
@@ -58,38 +85,44 @@ jobs:
         with:
           context: .
           load: true
-          tags: ${{ github.repository_owner }}/${{ matrix.image.name }}:experimental
+          tags: ${{ env.DOCKER_HUB_OWNER }}/${{ matrix.image.name }}:${{ env.TAG }}
           cache-from: type=gha
           build-contexts: ${{ matrix.image.name != 'grist-oss' && 'ext=ext' || '' }}
 
       - name: Use Node.js ${{ matrix.node-version }} for testing
+        if: ${{ !inputs.disable_tests }}
         uses: actions/setup-node@v1
         with:
           node-version: ${{ matrix.node-version }}
 
       - name: Set up Python ${{ matrix.python-version }} for testing - maybe not needed
+        if: ${{ !inputs.disable_tests }}
         uses: actions/setup-python@v2
         with:
           python-version: ${{ matrix.python-version }}
 
       - name: Install Python packages
+        if: ${{ !inputs.disable_tests }}
         run: |
           pip install virtualenv
           yarn run install:python
 
       - name: Install Node.js packages
+        if: ${{ !inputs.disable_tests }}
         run: yarn install
 
       - name: Build Node.js code
+        if: ${{ !inputs.disable_tests }}
         run: |
           rm -rf ext
           yarn run build:prod
 
       - name: Run tests
-        run: TEST_IMAGE=${{ github.repository_owner }}/${{ matrix.image.name }}:experimental VERBOSE=1 DEBUG=1 MOCHA_WEBDRIVER_HEADLESS=1 yarn run test:docker
+        if: ${{ !inputs.disable_tests }}
+        run: TEST_IMAGE=${{ env.DOCKER_HUB_OWNER }}/${{ matrix.image.name }}:${{ env.TAG }} VERBOSE=1 DEBUG=1 MOCHA_WEBDRIVER_HEADLESS=1 yarn run test:docker
 
       - name: Restore the ext/ directory
-        if: matrix.image.name != 'grist-oss'
+        if: ${{ matrix.image.name != 'grist-oss' && !inputs.disable_tests }}
         run: buildtools/checkout-ext-directory.sh ${{ matrix.image.repo }}
 
       - name: Log in to Docker Hub
@@ -102,13 +135,28 @@ jobs:
         uses: docker/build-push-action@v2
         with:
           context: .
-          platforms: linux/amd64,linux/arm64/v8
+          platforms: ${{ env.PLATFORMS }}
           push: true
-          tags: ${{ github.repository_owner }}/${{ matrix.image.name }}:experimental
+          tags: ${{ env.DOCKER_HUB_OWNER }}/${{ matrix.image.name }}:${{ env.TAG }}
           cache-from: type=gha
           cache-to: type=gha,mode=max
           build-contexts: ${{ matrix.image.name != 'grist-oss' && 'ext=ext' || '' }}
 
+      - name: Push Enterprise to Docker Hub
+        if: ${{ matrix.image.name == 'grist' }}
+        uses: docker/build-push-action@v2
+        with:
+          context: .
+          build-args: |
+            BASE_IMAGE=${{ env.DOCKER_HUB_OWNER }}/${{ matrix.image.name}}
+            BASE_VERSION=${{ env.TAG }}
+          file: ext/Dockerfile
+          platforms: ${{ env.PLATFORMS }}
+          push: true
+          tags: ${{ env.DOCKER_HUB_OWNER }}/grist-ee:${{ env.TAG }}
+          cache-from: type=gha
+          cache-to: type=gha,mode=max
+
   update_latest_branch:
     name: Update latest branch
     runs-on: ubuntu-latest
diff --git a/buildtools/.grist-ee-version b/buildtools/.grist-ee-version
index 2003b639..a602fc9e 100644
--- a/buildtools/.grist-ee-version
+++ b/buildtools/.grist-ee-version
@@ -1 +1 @@
-0.9.2
+0.9.4

From a999b4250e1f0e57e4c28ed184daead3d8fa4fc4 Mon Sep 17 00:00:00 2001
From: Spoffy <contact@spoffy.net>
Date: Mon, 8 Jul 2024 14:03:18 -0400
Subject: [PATCH 038/145] Fixes OSS including EE by providing empty ext dir

---
 .github/workflows/docker_latest.yml  | 4 ++--
 .gitignore                           | 3 +++
 buildtools/checkout-ext-directory.sh | 1 +
 ext/README.md                        | 5 +++++
 4 files changed, 11 insertions(+), 2 deletions(-)
 create mode 100644 ext/README.md

diff --git a/.github/workflows/docker_latest.yml b/.github/workflows/docker_latest.yml
index b3adb7ef..d72682cf 100644
--- a/.github/workflows/docker_latest.yml
+++ b/.github/workflows/docker_latest.yml
@@ -87,7 +87,7 @@ jobs:
           load: true
           tags: ${{ env.DOCKER_HUB_OWNER }}/${{ matrix.image.name }}:${{ env.TAG }}
           cache-from: type=gha
-          build-contexts: ${{ matrix.image.name != 'grist-oss' && 'ext=ext' || '' }}
+          build-contexts: ext=ext
 
       - name: Use Node.js ${{ matrix.node-version }} for testing
         if: ${{ !inputs.disable_tests }}
@@ -140,7 +140,7 @@ jobs:
           tags: ${{ env.DOCKER_HUB_OWNER }}/${{ matrix.image.name }}:${{ env.TAG }}
           cache-from: type=gha
           cache-to: type=gha,mode=max
-          build-contexts: ${{ matrix.image.name != 'grist-oss' && 'ext=ext' || '' }}
+          build-contexts: ext=ext
 
       - name: Push Enterprise to Docker Hub
         if: ${{ matrix.image.name == 'grist' }}
diff --git a/.gitignore b/.gitignore
index 1d2fa534..3ec43ff9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -80,3 +80,6 @@ xunit.xml
 .clipboard.lock
 
 **/_build
+
+# ext directory can be overwritten
+ext/**
diff --git a/buildtools/checkout-ext-directory.sh b/buildtools/checkout-ext-directory.sh
index 6861b9e2..81753e31 100755
--- a/buildtools/checkout-ext-directory.sh
+++ b/buildtools/checkout-ext-directory.sh
@@ -14,5 +14,6 @@ pushd $repo
 git sparse-checkout set ext
 git checkout
 popd
+rm -rf ./ext
 mv $repo/ext .
 rm -rf $repo
diff --git a/ext/README.md b/ext/README.md
new file mode 100644
index 00000000..09f9848c
--- /dev/null
+++ b/ext/README.md
@@ -0,0 +1,5 @@
+`ext` is a directory that allows derivatives of Grist core to be created, without modifying any of the base files.
+
+Files placed in here should be new files, or replacing files in the `stubs` directory.
+
+When compiling, Typescript resolves files in `ext` before files in `stubs`, using the `ext` file instead (if it exists).

From b8c4b83a8c06c82dcbc3205165eb01908dbc3686 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jaros=C5=82aw=20Sadzi=C5=84ski?=
 <jaroslaw.sadzinski@gmail.com>
Date: Tue, 9 Jul 2024 09:31:51 +0200
Subject: [PATCH 039/145] (core) Updating paths after core changed

Summary: Path for the HomeDbManager has beed updated after merging with core.

Test Plan: Existing

Reviewers: georgegevoian

Reviewed By: georgegevoian

Subscribers: georgegevoian

Differential Revision: https://phab.getgrist.com/D4288
---
 app/server/lib/DocStorageManager.ts    | 11 ++---------
 app/server/lib/HostedStorageManager.ts | 11 ++---------
 app/server/lib/IDocStorageManager.ts   | 11 ++++++++++-
 3 files changed, 14 insertions(+), 19 deletions(-)

diff --git a/app/server/lib/DocStorageManager.ts b/app/server/lib/DocStorageManager.ts
index 8879db42..7ec65de4 100644
--- a/app/server/lib/DocStorageManager.ts
+++ b/app/server/lib/DocStorageManager.ts
@@ -11,7 +11,7 @@ import * as gutil from 'app/common/gutil';
 import {Comm} from 'app/server/lib/Comm';
 import * as docUtils from 'app/server/lib/docUtils';
 import {GristServer} from 'app/server/lib/GristServer';
-import {IDocStorageManager, SnapshotProgress} from 'app/server/lib/IDocStorageManager';
+import {EmptySnapshotProgress, IDocStorageManager, SnapshotProgress} from 'app/server/lib/IDocStorageManager';
 import {IShell} from 'app/server/lib/IShell';
 import log from 'app/server/lib/log';
 import uuidv4 from "uuid/v4";
@@ -258,14 +258,7 @@ export class DocStorageManager implements IDocStorageManager {
   }
 
   public getSnapshotProgress(): SnapshotProgress {
-    return {
-      pushes: 0,
-      skippedPushes: 0,
-      errors: 0,
-      changes: 0,
-      windowsStarted: 0,
-      windowsDone: 0,
-    };
+    return new EmptySnapshotProgress();
   }
 
   public async replace(docName: string, options: any): Promise<void> {
diff --git a/app/server/lib/HostedStorageManager.ts b/app/server/lib/HostedStorageManager.ts
index fa73beb6..88e5317f 100644
--- a/app/server/lib/HostedStorageManager.ts
+++ b/app/server/lib/HostedStorageManager.ts
@@ -15,7 +15,7 @@ import {IDocWorkerMap} from 'app/server/lib/DocWorkerMap';
 import {ChecksummedExternalStorage, DELETED_TOKEN, ExternalStorage, Unchanged} from 'app/server/lib/ExternalStorage';
 import {HostedMetadataManager} from 'app/server/lib/HostedMetadataManager';
 import {ICreate} from 'app/server/lib/ICreate';
-import {IDocStorageManager, SnapshotProgress} from 'app/server/lib/IDocStorageManager';
+import {EmptySnapshotProgress, IDocStorageManager, SnapshotProgress} from 'app/server/lib/IDocStorageManager';
 import {LogMethods} from "app/server/lib/LogMethods";
 import {fromCallback} from 'app/server/lib/serverUtils';
 import * as fse from 'fs-extra';
@@ -232,14 +232,7 @@ export class HostedStorageManager implements IDocStorageManager {
   public getSnapshotProgress(docName: string): SnapshotProgress {
     let snapshotProgress = this._snapshotProgress.get(docName);
     if (!snapshotProgress) {
-      snapshotProgress = {
-        pushes: 0,
-        skippedPushes: 0,
-        errors: 0,
-        changes: 0,
-        windowsStarted: 0,
-        windowsDone: 0,
-      };
+      snapshotProgress = new EmptySnapshotProgress();
       this._snapshotProgress.set(docName, snapshotProgress);
     }
     return snapshotProgress;
diff --git a/app/server/lib/IDocStorageManager.ts b/app/server/lib/IDocStorageManager.ts
index 6bde92cb..40a1a951 100644
--- a/app/server/lib/IDocStorageManager.ts
+++ b/app/server/lib/IDocStorageManager.ts
@@ -68,7 +68,7 @@ export class TrivialDocStorageManager implements IDocStorageManager {
   public async flushDoc() {}
   public async getSnapshots(): Promise<never> { throw new Error('no'); }
   public async removeSnapshots(): Promise<never> { throw new Error('no'); }
-  public getSnapshotProgress(): SnapshotProgress { throw new Error('no'); }
+  public getSnapshotProgress(): SnapshotProgress { return new EmptySnapshotProgress(); }
   public async replace(): Promise<never> { throw new Error('no'); }
 }
 
@@ -116,3 +116,12 @@ export interface SnapshotProgress {
   /** Number of times a save window was completed. */
   windowsDone: number;
 }
+
+export class EmptySnapshotProgress implements SnapshotProgress {
+  public pushes: number = 0;
+  public skippedPushes: number = 0;
+  public errors: number = 0;
+  public changes: number = 0;
+  public windowsStarted: number = 0;
+  public windowsDone: number = 0;
+}

From 8b52d55a132f5db0fb453e5bbafac9b684ee0110 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= <jordigh@octave.org>
Date: Tue, 9 Jul 2024 15:58:08 -0400
Subject: [PATCH 040/145] (core) README: proofread the French

Summary: Change "bien" to "beaucoup" and add a space before the exclamation mark as is usually done in French.

Test Plan: None.

Reviewers: paulfitz

Reviewed By: paulfitz

Differential Revision: https://phab.getgrist.com/D4291
---
 README.md | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/README.md b/README.md
index 1f4f1b4e..dd44a011 100644
--- a/README.md
+++ b/README.md
@@ -202,7 +202,7 @@ and Google/Microsoft sign-ins via [Dex](https://dexidp.io/).
 
 We use [Weblate](https://hosted.weblate.org/engage/grist/) to manage translations.
 Thanks to everyone who is pitching in. Thanks especially to the ANCT developers who
-did the hard work of making a good chunk of the application localizable. Merci bien!
+did the hard work of making a good chunk of the application localizable. Merci beaucoup !
 
 <a href="https://hosted.weblate.org/engage/grist/">
 <img src="https://hosted.weblate.org/widgets/grist/-/open-graph.png" alt="Translation status" width=480 />

From d33629366b3e6d96458299e9fec4085afde70cf5 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= <jordigh@octave.org>
Date: Tue, 9 Jul 2024 16:09:55 -0400
Subject: [PATCH 041/145] grist-ee: bump version

---
 buildtools/.grist-ee-version | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/buildtools/.grist-ee-version b/buildtools/.grist-ee-version
index a602fc9e..b0bb8785 100644
--- a/buildtools/.grist-ee-version
+++ b/buildtools/.grist-ee-version
@@ -1 +1 @@
-0.9.4
+0.9.5

From 39eb042ff166626a923bb07c9e8eb557d99b5e6e Mon Sep 17 00:00:00 2001
From: Florent <florent.git@zeteo.me>
Date: Wed, 10 Jul 2024 20:28:20 +0200
Subject: [PATCH 042/145] Remove GRIST_SKIP_REDIS_CHECKSUM_MISMATCH (#1098)

Skipping the redis checksum mismatch is now generalized. A warning is
logged when we see a mismatch.
---
 README.md                               |  1 -
 app/server/lib/ExternalStorage.ts       | 16 ++--------------
 test/server/lib/HostedStorageManager.ts | 12 +++---------
 3 files changed, 5 insertions(+), 24 deletions(-)

diff --git a/README.md b/README.md
index dd44a011..92de74e8 100644
--- a/README.md
+++ b/README.md
@@ -316,7 +316,6 @@ Grist can be configured in many ways. Here are the main environment variables it
 | HOME_PORT                          | port number to listen on for REST API server; if set to "share", add API endpoints to regular grist port.                                                                                                                                                                                                                                                     |
 | PORT                               | port number to listen on for Grist server                                                                                                                                                                                                                                                                                                                     |
 | REDIS_URL                          | optional redis server for browser sessions and db query caching                                                                                                                                                                                                                                                                                               |
-| GRIST_SKIP_REDIS_CHECKSUM_MISMATCH | Experimental. If set, only warn if the checksum in Redis differs with the one in your S3 backend storage. You may turn it on if your backend storage implements the [read-after-write consistency](https://aws.amazon.com/fr/blogs/aws/amazon-s3-update-strong-read-after-write-consistency/). Defaults to false.                                             |
 | GRIST_SNAPSHOT_TIME_CAP            | optional. Define the caps for tracking buckets. Usage: {"hour": 25, "day": 32, "isoWeek": 12, "month": 96, "year": 1000}                                                                                                                                                                                                                                      |
 | GRIST_SNAPSHOT_KEEP                | optional. Number of recent snapshots to retain unconditionally for a document, regardless of when they were made                                                                                                                                                                                                                                              |
 | GRIST_PROMCLIENT_PORT              | optional. If set, serve the Prometheus metrics on the specified port number. ⚠️ Be sure to use a port which is not publicly exposed ⚠️.                                                                                                                                                                                                                         |
diff --git a/app/server/lib/ExternalStorage.ts b/app/server/lib/ExternalStorage.ts
index d5636109..c92fb0bb 100644
--- a/app/server/lib/ExternalStorage.ts
+++ b/app/server/lib/ExternalStorage.ts
@@ -1,5 +1,4 @@
 import {ObjMetadata, ObjSnapshot, ObjSnapshotWithMetadata} from 'app/common/DocSnapshot';
-import {isAffirmative} from 'app/common/gutil';
 import log from 'app/server/lib/log';
 import {createTmpDir} from 'app/server/lib/uploads';
 
@@ -236,19 +235,8 @@ export class ChecksummedExternalStorage implements ExternalStorage {
           // We are confident this should not be the case anymore, though this has to be studied carefully.
           // If a snapshotId was specified, we can skip this check.
           if (expectedChecksum && expectedChecksum !== checksum) {
-            const message = `ext ${this.label} download: data for ${fromKey} has wrong checksum:` +
-              ` ${checksum} (expected ${expectedChecksum})`;
-
-            // If GRIST_SKIP_REDIS_CHECKSUM_MISMATCH is set, issue a warning only and continue,
-            // rather than issuing an error and failing.
-            // This flag is experimental and should be removed once we are
-            // confident that the checksums verification is useless.
-            if (isAffirmative(process.env.GRIST_SKIP_REDIS_CHECKSUM_MISMATCH)) {
-              log.warn(message);
-            } else {
-              log.error(message);
-              return undefined;
-            }
+            log.warn(`ext ${this.label} download: data for ${fromKey} has wrong checksum:` +
+              ` ${checksum} (expected ${expectedChecksum})`);
           }
         }
 
diff --git a/test/server/lib/HostedStorageManager.ts b/test/server/lib/HostedStorageManager.ts
index 2fee78c2..aa164892 100644
--- a/test/server/lib/HostedStorageManager.ts
+++ b/test/server/lib/HostedStorageManager.ts
@@ -494,18 +494,12 @@ describe('HostedStorageManager', function() {
         await setRedisChecksum(docId, 'nobble');
         await store.removeAll();
 
-        // With GRIST_SKIP_REDIS_CHECKSUM_MISMATCH set, the fetch should work
-        process.env.GRIST_SKIP_REDIS_CHECKSUM_MISMATCH = 'true';
+        const warnSpy = sandbox.spy(log, 'warn');
         await store.run(async () => {
           await assert.isFulfilled(store.docManager.fetchDoc(docSession, docId));
+          assert.isTrue(warnSpy.calledWithMatch('has wrong checksum'), 'a warning should have been logged');
         });
-
-        // By default, the fetch should eventually errors.
-        delete process.env.GRIST_SKIP_REDIS_CHECKSUM_MISMATCH;
-        await store.run(async () => {
-          await assert.isRejected(store.docManager.fetchDoc(docSession, docId),
-                                  /operation failed to become consistent/);
-        });
+        warnSpy.restore();
 
         // Check we get the document back on fresh start if checksum is correct.
         await setRedisChecksum(docId, checksum);

From 78f5bd9f5d07a52abf577eaa5eb319b04359d677 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= <jordigh@octave.org>
Date: Wed, 10 Jul 2024 13:08:19 -0400
Subject: [PATCH 043/145] workflow: conditionally restore ext/ directory

Now that we *have* to have something in the ext directory, we need to
either restore the external ext or the default OSS ext depending on
which build we are doing.
---
 .github/workflows/docker_latest.yml | 14 ++++++++------
 1 file changed, 8 insertions(+), 6 deletions(-)

diff --git a/.github/workflows/docker_latest.yml b/.github/workflows/docker_latest.yml
index d72682cf..0320eb5b 100644
--- a/.github/workflows/docker_latest.yml
+++ b/.github/workflows/docker_latest.yml
@@ -111,19 +111,21 @@ jobs:
         if: ${{ !inputs.disable_tests }}
         run: yarn install
 
+      - name: Disable the ext/ directory
+        if: ${{ !inputs.disable_tests }}
+        run: mv ext/ ext-disabled/
+
       - name: Build Node.js code
         if: ${{ !inputs.disable_tests }}
-        run: |
-          rm -rf ext
-          yarn run build:prod
+        run: yarn run build:prod
 
       - name: Run tests
         if: ${{ !inputs.disable_tests }}
         run: TEST_IMAGE=${{ env.DOCKER_HUB_OWNER }}/${{ matrix.image.name }}:${{ env.TAG }} VERBOSE=1 DEBUG=1 MOCHA_WEBDRIVER_HEADLESS=1 yarn run test:docker
 
-      - name: Restore the ext/ directory
-        if: ${{ matrix.image.name != 'grist-oss' && !inputs.disable_tests }}
-        run: buildtools/checkout-ext-directory.sh ${{ matrix.image.repo }}
+      - name: Re-enable the ext/ directory
+        if: ${{ !inputs.disable_tests }}
+        run: mv ext-disabled/ ext/
 
       - name: Log in to Docker Hub
         uses: docker/login-action@v1 

From 8f443a3d7802a8c6c0271fdea482c0d1c910779c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= <jordigh@octave.org>
Date: Thu, 11 Jul 2024 12:55:55 -0400
Subject: [PATCH 044/145] workflows: move experimental tag back to latest

Our builds seem to be fine now. It is time to release boldly and
widely the new Docker images to the `latest` tag.
---
 .github/workflows/docker_latest.yml | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/.github/workflows/docker_latest.yml b/.github/workflows/docker_latest.yml
index 0320eb5b..129d109e 100644
--- a/.github/workflows/docker_latest.yml
+++ b/.github/workflows/docker_latest.yml
@@ -34,12 +34,12 @@ on:
         description: "Tag for the resulting images"
         type: string
         required: True
-        default: 'experimental'
+        default: 'latest'
 
 env:
   BRANCH: ${{ inputs.branch || 'latest_candidate' }}
   PLATFORMS: ${{ inputs.platforms || 'linux/amd64,linux/arm64/v8' }}
-  TAG: ${{ inputs.tag || 'experimental' }}
+  TAG: ${{ inputs.tag || 'latest' }}
   DOCKER_HUB_OWNER: ${{ vars.DOCKER_HUB_OWNER || github.repository_owner }}
 
 jobs:

From a437dfa28c7139bb436d8c1c88f674c9f16bb87a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= <jordigh@octave.org>
Date: Thu, 11 Jul 2024 16:00:30 -0400
Subject: [PATCH 045/145] workflows: explicitly add a dummy ext/ directory

Having it checked in to git caused problems with Grist Desktop and
Grist Static because their build processes expected to have nothing
there, as well as interfering with checking out Grist Core as a
submodule.

So we do this instead.
---
 .github/workflows/docker_latest.yml | 4 ++++
 ext/README.md                       | 5 -----
 2 files changed, 4 insertions(+), 5 deletions(-)
 delete mode 100644 ext/README.md

diff --git a/.github/workflows/docker_latest.yml b/.github/workflows/docker_latest.yml
index 129d109e..7069ab50 100644
--- a/.github/workflows/docker_latest.yml
+++ b/.github/workflows/docker_latest.yml
@@ -70,6 +70,10 @@ jobs:
         with:
           ref: ${{ env.BRANCH }}
 
+      - name: Add a dummy ext/ directory
+        run:
+          mkdir ext && touch ext/dummy
+
       - name: Check out the ext/ directory
         if: matrix.image.name != 'grist-oss'
         run: buildtools/checkout-ext-directory.sh ${{ matrix.image.repo }}
diff --git a/ext/README.md b/ext/README.md
deleted file mode 100644
index 09f9848c..00000000
--- a/ext/README.md
+++ /dev/null
@@ -1,5 +0,0 @@
-`ext` is a directory that allows derivatives of Grist core to be created, without modifying any of the base files.
-
-Files placed in here should be new files, or replacing files in the `stubs` directory.
-
-When compiling, Typescript resolves files in `ext` before files in `stubs`, using the `ext` file instead (if it exists).

From 632620544c43a66a6341097bdbdad19fff23397c Mon Sep 17 00:00:00 2001
From: Leslie H <142967379+SleepyLeslie@users.noreply.github.com>
Date: Fri, 12 Jul 2024 10:58:49 -0400
Subject: [PATCH 046/145] Update dropdown conditions on column rename (#1038)

Automatically update dropdown condition formulas on Ref, RefList, Choice and ChoiceList columns when a column referred to has been renamed.
Also fixed column references in ACL formulas using the "$" notation not being properly renamed.
---
 sandbox/grist/acl.py                          | 103 ++++++++----
 sandbox/grist/acl_formula.py                  |  50 ------
 sandbox/grist/codebuilder.py                  |   9 +-
 sandbox/grist/dropdown_condition.py           |  69 +++++++-
 sandbox/grist/predicate_formula.py            |  59 ++++++-
 sandbox/grist/test_acl_renames.py             |  18 +-
 .../grist/test_dropdown_condition_renames.py  | 158 ++++++++++++++++++
 sandbox/grist/test_predicate_formula.py       |  12 +-
 sandbox/grist/useractions.py                  |  21 ++-
 sandbox/grist/usertypes.py                    |   7 +
 10 files changed, 392 insertions(+), 114 deletions(-)
 delete mode 100644 sandbox/grist/acl_formula.py
 create mode 100644 sandbox/grist/test_dropdown_condition_renames.py

diff --git a/sandbox/grist/acl.py b/sandbox/grist/acl.py
index b3c46136..278bb922 100644
--- a/sandbox/grist/acl.py
+++ b/sandbox/grist/acl.py
@@ -5,10 +5,9 @@
 import json
 import logging
 
-from acl_formula import parse_acl_grist_entities
-from predicate_formula import parse_predicate_formula_json
 import action_obj
-import textbuilder
+import predicate_formula
+from predicate_formula import NamedEntity, parse_predicate_formula_json, TreeConverter
 
 log = logging.getLogger(__name__)
 
@@ -32,6 +31,40 @@ ALL = '#ALL'
 ALL_SET = frozenset([ALL])
 
 
+def parse_acl_formulas(col_values):
+  """
+  Populates `aclFormulaParsed` by parsing `aclFormula` for all `col_values`.
+  """
+  if 'aclFormula' not in col_values:
+    return
+
+  col_values['aclFormulaParsed'] = [parse_predicate_formula_json(v)
+                                    for v
+                                    in col_values['aclFormula']]
+
+
+class _ACLEntityCollector(TreeConverter):
+  def __init__(self):
+    self.entities = []    # NamedEntity list
+
+  def visit_Attribute(self, node):
+    parent = self.visit(node.value)
+
+    # We recognize a couple of specific patterns for entities that may be affected by renames.
+    if parent == ['Name', 'rec'] or parent == ['Name', 'newRec']:
+      # rec.COL refers to the column from the table that the rule is on.
+      self.entities.append(NamedEntity('recCol', node.last_token.startpos, node.attr, None))
+    elif parent == ['Name', 'user']:
+      # user.ATTR is a user attribute.
+      self.entities.append(NamedEntity('userAttr', node.last_token.startpos, node.attr, None))
+    elif parent[0] == 'Attr' and parent[1] == ['Name', 'user']:
+      # user.ATTR.COL is a column from the lookup table of the UserAttribute ATTR.
+      self.entities.append(
+          NamedEntity('userAttrCol', node.last_token.startpos, node.attr, parent[2]))
+
+    return ["Attr", parent, node.attr]
+
+
 def acl_read_split(action_group):
   """
   Returns an ActionBundle containing actions from the given action_group, all in one envelope.
@@ -48,20 +81,20 @@ def acl_read_split(action_group):
   return bundle
 
 
-def prepare_acl_table_renames(docmodel, useractions, table_renames_dict):
+def prepare_acl_table_renames(useractions, table_renames_dict):
   """
   Given a dict of table renames of the form {table_id: new_table_id}, returns a callback
   that will apply updates to the affected ACL rules and resources.
   """
   # If there are ACLResources that refer to the renamed table, prepare updates for those.
   resource_updates = []
-  for resource_rec in docmodel.aclResources.all:
+  for resource_rec in useractions.get_docmodel().aclResources.all:
     if resource_rec.tableId in table_renames_dict:
       resource_updates.append((resource_rec, {'tableId': table_renames_dict[resource_rec.tableId]}))
 
   # Collect updates for any ACLRules with UserAttributes that refer to the renamed table.
   rule_updates = []
-  for rule_rec in docmodel.aclRules.all:
+  for rule_rec in useractions.get_docmodel().aclRules.all:
     if rule_rec.userAttributes:
       try:
         rule_info = json.loads(rule_rec.userAttributes)
@@ -77,14 +110,14 @@ def prepare_acl_table_renames(docmodel, useractions, table_renames_dict):
   return do_renames
 
 
-def prepare_acl_col_renames(docmodel, useractions, col_renames_dict):
+def perform_acl_rule_renames(useractions, col_renames_dict):
   """
   Given a dict of column renames of the form {(table_id, col_id): new_col_id}, returns a callback
   that will apply updates to the affected ACL rules and resources.
   """
   # Collect updates for ACLResources that refer to the renamed columns.
   resource_updates = []
-  for resource_rec in docmodel.aclResources.all:
+  for resource_rec in useractions.get_docmodel().aclResources.all:
     t = resource_rec.tableId
     if resource_rec.colIds and resource_rec.colIds != '*':
       new_col_ids = ','.join((col_renames_dict.get((t, c)) or c)
@@ -95,7 +128,7 @@ def prepare_acl_col_renames(docmodel, useractions, col_renames_dict):
   # Collect updates for any ACLRules with UserAttributes that refer to the renamed column.
   rule_updates = []
   user_attr_tables = {}   # Maps name of user attribute to its lookup table
-  for rule_rec in docmodel.aclRules.all:
+  for rule_rec in useractions.get_docmodel().aclRules.all:
     if rule_rec.userAttributes:
       try:
         rule_info = json.loads(rule_rec.userAttributes)
@@ -107,33 +140,33 @@ def prepare_acl_col_renames(docmodel, useractions, col_renames_dict):
       except Exception as e:
         log.warning("Error examining aclRule: %s", e)
 
+  acl_resources_table = useractions.get_docmodel().aclResources.table
   # Go through again checking if anything in ACL formulas is affected by the rename.
-  for rule_rec in docmodel.aclRules.all:
-    if rule_rec.aclFormula:
-      formula = rule_rec.aclFormula
-      patches = []
+  for rule_rec in useractions.get_docmodel().aclRules.all:
 
-      for entity in parse_acl_grist_entities(rule_rec.aclFormula):
-        if entity.type == 'recCol':
-          table_id = docmodel.aclResources.table.get_record(int(rule_rec.resource)).tableId
-        elif entity.type == 'userAttrCol':
-          table_id = user_attr_tables.get(entity.extra)
-        else:
-          continue
-        col_id = entity.name
-        new_col_id = col_renames_dict.get((table_id, col_id))
-        if not new_col_id:
-          continue
-        patch = textbuilder.make_patch(
-            formula, entity.start_pos, entity.start_pos + len(entity.name), new_col_id)
-        patches.append(patch)
+    if not rule_rec.aclFormula:
+      continue
+    acl_formula = rule_rec.aclFormula
 
-      replacer = textbuilder.Replacer(textbuilder.Text(formula), patches)
-      txt = replacer.get_text()
-      rule_updates.append((rule_rec, {'aclFormula': txt,
-                                      'aclFormulaParsed': parse_predicate_formula_json(txt)}))
+    def renamer(subject):
+      if subject.type == 'recCol':
+        table_id = acl_resources_table.get_record(int(rule_rec.resource)).tableId
+      elif subject.type == 'userAttrCol':
+        table_id = user_attr_tables.get(subject.extra)
+      else:
+        return None
+      col_id = subject.name
+      return col_renames_dict.get((table_id, col_id))
 
-  def do_renames():
-    useractions.doBulkUpdateFromPairs('_grist_ACLResources', resource_updates)
-    useractions.doBulkUpdateFromPairs('_grist_ACLRules', rule_updates)
-  return do_renames
+    new_acl_formula = predicate_formula.process_renames(acl_formula, _ACLEntityCollector(), renamer)
+    # No need to check for syntax errors, but this "if" statement must be present.
+    # See perform_dropdown_condition_renames for more info.
+    if new_acl_formula != acl_formula:
+      new_rule_record = {
+        "aclFormula": new_acl_formula,
+        "aclFormulaParsed": parse_predicate_formula_json(new_acl_formula)
+      }
+      rule_updates.append((rule_rec, new_rule_record))
+
+  useractions.doBulkUpdateFromPairs('_grist_ACLResources', resource_updates)
+  useractions.doBulkUpdateFromPairs('_grist_ACLRules', rule_updates)
diff --git a/sandbox/grist/acl_formula.py b/sandbox/grist/acl_formula.py
deleted file mode 100644
index 19a1d63b..00000000
--- a/sandbox/grist/acl_formula.py
+++ /dev/null
@@ -1,50 +0,0 @@
-import ast
-
-import asttokens
-
-from predicate_formula import NamedEntity, parse_predicate_formula_json, TreeConverter
-
-def parse_acl_formulas(col_values):
-  """
-  Populates `aclFormulaParsed` by parsing `aclFormula` for all `col_values`.
-  """
-  if 'aclFormula' not in col_values:
-    return
-
-  col_values['aclFormulaParsed'] = [parse_predicate_formula_json(v)
-                                    for v
-                                    in col_values['aclFormula']]
-
-def parse_acl_grist_entities(acl_formula):
-  """
-  Parse the ACL formula collecting any entities that may be subject to renaming. Returns a
-  NamedEntity list.
-  """
-  try:
-    atok = asttokens.ASTTokens(acl_formula, tree=ast.parse(acl_formula, mode='eval'))
-    converter = _EntityCollector()
-    converter.visit(atok.tree)
-    return converter.entities
-  except SyntaxError as err:
-    return []
-
-class _EntityCollector(TreeConverter):
-  def __init__(self):
-    self.entities = []    # NamedEntity list
-
-  def visit_Attribute(self, node):
-    parent = self.visit(node.value)
-
-    # We recognize a couple of specific patterns for entities that may be affected by renames.
-    if parent == ['Name', 'rec'] or parent == ['Name', 'newRec']:
-      # rec.COL refers to the column from the table that the rule is on.
-      self.entities.append(NamedEntity('recCol', node.last_token.startpos, node.attr, None))
-    if parent == ['Name', 'user']:
-      # user.ATTR is a user attribute.
-      self.entities.append(NamedEntity('userAttr', node.last_token.startpos, node.attr, None))
-    elif parent[0] == 'Attr' and parent[1] == ['Name', 'user']:
-      # user.ATTR.COL is a column from the lookup table of the UserAttribute ATTR.
-      self.entities.append(
-          NamedEntity('userAttrCol', node.last_token.startpos, node.attr, parent[2]))
-
-    return ["Attr", parent, node.attr]
diff --git a/sandbox/grist/codebuilder.py b/sandbox/grist/codebuilder.py
index f65022bc..6f608501 100644
--- a/sandbox/grist/codebuilder.py
+++ b/sandbox/grist/codebuilder.py
@@ -132,10 +132,11 @@ def make_formula_body(formula, default_value, assoc_value=None):
   return final_formula
 
 
-def replace_dollar_attrs(formula):
+def get_dollar_replacer(formula):
   """
-  Translates formula "$" expression into rec. expression. This is extracted from the
-  make_formula_body function.
+  Returns a textbuilder.Replacer that would replace all dollar signs ("$") in the given
+  formula with "rec.". The Replacer tracks extra info we can later use to restore the
+  dollar signs back. To get the processed text, call .get_text() on the Replacer.
   """
   formula_builder_text = textbuilder.Text(formula)
   tmp_patches = textbuilder.make_regexp_patches(formula, DOLLAR_REGEX, 'DOLLAR')
@@ -150,7 +151,7 @@ def replace_dollar_attrs(formula):
       if m:
         patches.append(textbuilder.make_patch(formula, m.start(0), m.end(0), 'rec.'))
   final_formula = textbuilder.Replacer(formula_builder_text, patches)
-  return final_formula.get_text()
+  return final_formula
 
 
 def _create_syntax_error_code(builder, input_text, err):
diff --git a/sandbox/grist/dropdown_condition.py b/sandbox/grist/dropdown_condition.py
index b57ef3f0..c590db71 100644
--- a/sandbox/grist/dropdown_condition.py
+++ b/sandbox/grist/dropdown_condition.py
@@ -1,10 +1,77 @@
 import json
 import logging
+import usertypes
 
-from predicate_formula import parse_predicate_formula_json
+from predicate_formula import NamedEntity, parse_predicate_formula_json, TreeConverter
+import predicate_formula
 
 log = logging.getLogger(__name__)
 
+class _DCEntityCollector(TreeConverter):
+  def __init__(self):
+    self.entities = []
+
+  def visit_Attribute(self, node):
+    parent = self.visit(node.value)
+
+    if parent == ["Name", "choice"]:
+      self.entities.append(NamedEntity("choiceAttr", node.last_token.startpos, node.attr, None))
+    elif parent == ["Name", "rec"]:
+      self.entities.append(NamedEntity("recCol", node.last_token.startpos, node.attr, None))
+
+    return ["Attr", parent, node.attr]
+
+
+def perform_dropdown_condition_renames(useractions, renames):
+  """
+  Given a dict of column renames of the form {(table_id, col_id): new_col_id}, applies updates
+  to the affected dropdown condition formulas.
+  """
+  updates = []
+
+  for col in useractions.get_docmodel().columns.all:
+
+    # Find all columns in the document that have dropdown conditions.
+    try:
+      widget_options = json.loads(col.widgetOptions)
+      dc_formula = widget_options["dropdownCondition"]["text"]
+    except (json.JSONDecodeError, KeyError):
+      continue
+
+    # Find out what table this column refers to and belongs to.
+    ref_table_id = usertypes.get_referenced_table_id(col.type)
+    self_table_id = col.parentId.tableId
+
+    def renamer(subject):
+      # subject.type is either choiceAttr or recCol, see _DCEntityCollector.
+      table_id = ref_table_id if subject.type == "choiceAttr" else self_table_id
+      # Dropdown conditions stay in widgetOptions, even when the current column type can't make
+      # use of them. Thus, attributes of "choice" do not make sense for columns other than Ref and
+      # RefList, but they may exist.
+      # We set ref_table_id to None in this case, so table_id will be None for stray choiceAttrs,
+      # therefore the subject will not be renamed.
+      # Columns of "rec" are still renamed accordingly.
+      return renames.get((table_id, subject.name))
+
+    new_dc_formula = predicate_formula.process_renames(dc_formula, _DCEntityCollector(), renamer)
+
+    # The data engine stops processing remaining formulas when it hits an internal exception during
+    # this renaming procedure. Parsing could potentially raise SyntaxErrors, so we must be careful
+    # not to parse a possibly syntactically wrong formula, or handle SyntaxErrors explicitly.
+    # Note that new_dc_formula was obtained from process_renames, where syntactically wrong formulas
+    # are left untouched. It is anticipated that rename-induced changes will not introduce new
+    # SyntaxErrors, so if the formula text is updated, the new version must be valid, hence safe
+    # to parse without error handling.
+    # This also serves as an optimization to avoid unnecessary parsing operations.
+    if new_dc_formula != dc_formula:
+      widget_options["dropdownCondition"]["text"] = new_dc_formula
+      widget_options["dropdownCondition"]["parsed"] = parse_predicate_formula_json(new_dc_formula)
+      updates.append((col, {"widgetOptions": json.dumps(widget_options)}))
+
+  # Update the dropdown condition in the database.
+  useractions.doBulkUpdateFromPairs('_grist_Tables_column', updates)
+
+
 def parse_dropdown_conditions(col_values):
   """
   Parses any unparsed dropdown conditions in `col_values`.
diff --git a/sandbox/grist/predicate_formula.py b/sandbox/grist/predicate_formula.py
index f4213833..b74bdaac 100644
--- a/sandbox/grist/predicate_formula.py
+++ b/sandbox/grist/predicate_formula.py
@@ -2,11 +2,12 @@ import ast
 import io
 import json
 import tokenize
+import sys
 from collections import namedtuple
-
+import asttokens
+import textbuilder
 import six
-
-from codebuilder import replace_dollar_attrs
+from codebuilder import get_dollar_replacer
 
 # Entities encountered in predicate formulas, which may get renamed.
 #   type : 'recCol'|'userAttr'|'userAttrCol',
@@ -38,7 +39,7 @@ def parse_predicate_formula(formula):
   if isinstance(formula, six.binary_type):
     formula = formula.decode('utf8')
   try:
-    formula = replace_dollar_attrs(formula)
+    formula = get_dollar_replacer(formula).get_text()
     tree = ast.parse(formula, mode='eval')
     result = TreeConverter().visit(tree)
     for part in tokenize.generate_tokens(io.StringIO(formula).readline):
@@ -46,9 +47,12 @@ def parse_predicate_formula(formula):
         result = ['Comment', result, part[1][1:].strip()]
         break
     return result
-  except SyntaxError as err:
+  except SyntaxError as e:
     # In case of an error, include line and offset.
-    raise SyntaxError("%s on line %s col %s" % (err.args[0], err.lineno, err.offset))
+    _, _, exc_traceback = sys.exc_info()
+    six.reraise(SyntaxError,
+                SyntaxError("%s on line %s col %s" % (e.args[0], e.lineno, e.offset)),
+                exc_traceback)
 
 def parse_predicate_formula_json(formula):
   """
@@ -63,6 +67,45 @@ named_constants = {
   'None': None,
 }
 
+
+def process_renames(formula, collector, renamer):
+  """
+  Given a predicate formula, a collector and a renamer, rename all references in the formula
+  that the renamer wants to rename. This is used to automatically update references in an ACL
+  or dropdown condition formula when a column it refers to has been renamed.
+
+  The collector should be a subclass of TreeConverter that collects related NamedEntity's and
+  stores them in the field "entities". See acl._ACLEntityCollector for an example.
+
+  The renamer should be a function taking a NamedEntity as its only argument. It should return
+  a new name for this NamedEntity when it wants to rename this entity, or None otherwise.
+  """
+  patches = []
+  # "$" can be used to refer to "rec." in Grist formulas, but it is not valid Python.
+  # We need to replace it with "rec." before parsing the formula, and restore it back after
+  # the surgery.
+  # Keep the dollar replacer object, so that later we know how to restore properly.
+  dollar_replacer = get_dollar_replacer(formula)
+  formula_nodollar = dollar_replacer.get_text()
+  try:
+    atok = asttokens.ASTTokens(formula_nodollar, tree=ast.parse(formula_nodollar, mode='eval'))
+    collector.visit(atok.tree)
+  except SyntaxError:
+    # Don't do anything to a syntactically wrong formula.
+    return formula
+
+  for subject in collector.entities:
+    new_name = renamer(subject)
+    if new_name is not None:
+      _, _, patch = dollar_replacer.map_back_patch(
+        textbuilder.make_patch(dollar_replacer.get_text(), subject.start_pos,
+                               subject.start_pos + len(subject.name), new_name)
+      )
+      patches.append(patch)
+
+  return textbuilder.Replacer(textbuilder.Text(formula), patches).get_text()
+
+
 class TreeConverter(ast.NodeVisitor):
   # AST nodes are documented here: https://docs.python.org/2/library/ast.html#abstract-grammar
   # pylint:disable=no-self-use
@@ -86,7 +129,7 @@ class TreeConverter(ast.NodeVisitor):
   def visit_Compare(self, node):
     # We don't try to support chained comparisons like "1 < 2 < 3" (though it wouldn't be hard).
     if len(node.ops) != 1 or len(node.comparators) != 1:
-      raise ValueError("Can't use chained comparisons")
+      raise SyntaxError("Can't use chained comparisons")
     return [node.ops[0].__class__.__name__, self.visit(node.left), self.visit(node.comparators[0])]
 
   def visit_Name(self, node):
@@ -115,4 +158,4 @@ class TreeConverter(ast.NodeVisitor):
     return self.visit_List(node)    # We don't distinguish tuples and lists
 
   def generic_visit(self, node):
-    raise ValueError("Unsupported syntax at %s:%s" % (node.lineno, node.col_offset + 1))
+    raise SyntaxError("Unsupported syntax at %s:%s" % (node.lineno, node.col_offset + 1))
diff --git a/sandbox/grist/test_acl_renames.py b/sandbox/grist/test_acl_renames.py
index be342e39..908694ef 100644
--- a/sandbox/grist/test_acl_renames.py
+++ b/sandbox/grist/test_acl_renames.py
@@ -40,6 +40,12 @@ class TestACLRenames(test_engine.EngineTestCase):
         'aclFormula': '( rec.schoolName !=  # ünîcødé comment\n  user.School.name)',
         'permissionsText': 'none',
       }],
+      ['AddRecord', '_grist_ACLRules', None, {
+        'resource': -2,
+        # Test whether both "$" and "rec." are preserved while renaming.
+        'aclFormula': '( $firstName not in rec.schoolName or $schoolName + $lastName == rec.firstName)',
+        'permissionsText': 'all',
+      }],
       ['AddRecord', '_grist_ACLRules', None, {
         'resource': -3,
         'permissionsText': 'all'
@@ -57,7 +63,8 @@ class TestACLRenames(test_engine.EngineTestCase):
       ['id',  'resource', 'aclFormula', 'permissionsText', 'userAttributes'],
       [1,     1,          '',           '',                json.dumps(user_attr1)],
       [2,     2,  '( rec.schoolName !=  # ünîcødé comment\n  user.School.name)', 'none', ''],
-      [3,     3,          '',           'all',              ''],
+      [3,     2,  '( $firstName not in rec.schoolName or $schoolName + $lastName == rec.firstName)', 'all', ''],
+      [4,     3,          '',           'all',              ''],
     ])
 
   def test_acl_table_renames(self):
@@ -78,7 +85,8 @@ class TestACLRenames(test_engine.EngineTestCase):
       ['id',  'resource', 'aclFormula', 'permissionsText', 'userAttributes'],
       [1,     1,          '',           '',                json.dumps(user_attr1_renamed)],
       [2,     2,  '( rec.schoolName !=  # ünîcødé comment\n  user.School.name)', 'none', ''],
-      [3,     3,          '',           'all',              ''],
+      [3,     2,  '( $firstName not in rec.schoolName or $schoolName + $lastName == rec.firstName)', 'all', ''],
+      [4,     3,          '',           'all',              ''],
     ])
 
   def test_acl_column_renames(self):
@@ -101,7 +109,8 @@ class TestACLRenames(test_engine.EngineTestCase):
       ['id',  'resource', 'aclFormula', 'permissionsText', 'userAttributes'],
       [1,     1,          '',           '',                json.dumps(user_attr1_renamed)],
       [2,     2,  '( rec.escuela !=  # ünîcødé comment\n  user.School.schoolName)', 'none', ''],
-      [3,     3,          '',           'all',              ''],
+      [3,     2,  '( $firstName not in rec.escuela or $escuela + $Family_Name == rec.firstName)', 'all', ''],
+      [4,     3,          '',           'all',              ''],
     ])
 
   def test_multiple_renames(self):
@@ -123,5 +132,6 @@ class TestACLRenames(test_engine.EngineTestCase):
       ['id',  'resource', 'aclFormula', 'permissionsText', 'userAttributes'],
       [1,     1,          '',           '',                json.dumps(user_attr1)],
       [2,     2,  '( rec.escuela !=  # ünîcødé comment\n  user.School.schoolName)', 'none', ''],
-      [3,     3,          '',           'all',              ''],
+      [3,     2,  '( $Given_Name not in rec.escuela or $escuela + $Family_Name == rec.Given_Name)', 'all', ''],
+      [4,     3,          '',           'all',              ''],
     ])
diff --git a/sandbox/grist/test_dropdown_condition_renames.py b/sandbox/grist/test_dropdown_condition_renames.py
new file mode 100644
index 00000000..26ef29b5
--- /dev/null
+++ b/sandbox/grist/test_dropdown_condition_renames.py
@@ -0,0 +1,158 @@
+# -*- coding: utf-8 -*-
+
+import json
+
+import test_engine
+import testsamples
+import useractions
+
+# A sample dropdown condition formula for the column Schools.address and alike, of type Ref/RefList.
+def build_dc1_text(school_name, address_city):
+  return "'New' in choice.{address_city} and ${school_name} == rec.{school_name} + rec.choice.city or choice.rec.city != $name2".format(**locals())
+
+# Another sample formula for a new column of type ChoiceList (or actually, anything other than Ref/RefList).
+def build_dc2_text(school_name, school_address):
+  # We currently don't support layered attribute access, e.g. rec.address.city, so this is not tested.
+  # choice.city really is nonsense, as choice will not be an object.
+  # Just for testing purposes, to make sure nothing is renamed here.
+  return "choice + ${school_name} == choice.city or rec.{school_address} > 2".format(**locals())
+
+def build_dc1(school_name, address_city):
+  return json.dumps({
+    "dropdownCondition": {
+      "text": build_dc1_text(school_name, address_city),
+      # The ModifyColumn user action should trigger an auto parse.
+      # "parsed" is stored as dumped JSON, so we need to explicitly dump it here as well.
+      "parsed": json.dumps(["Or", ["And", ["In", ["Const", "New"], ["Attr", ["Name", "choice"], address_city]], ["Eq", ["Attr", ["Name", "rec"], school_name], ["Add", ["Attr", ["Name", "rec"], school_name], ["Attr", ["Attr", ["Name", "rec"], "choice"], "city"]]]], ["NotEq", ["Attr", ["Attr", ["Name", "choice"], "rec"], "city"], ["Attr", ["Name", "rec"], "name2"]]])
+    }
+  })
+
+def build_dc2(school_name, school_address):
+  return json.dumps({
+    "dropdownCondition": {
+      "text": build_dc2_text(school_name, school_address),
+      "parsed": json.dumps(["Or", ["Eq", ["Add", ["Name", "choice"], ["Attr", ["Name", "rec"], school_name]], ["Attr", ["Name", "choice"], "city"]], ["Gt", ["Attr", ["Name", "rec"], school_address], ["Const", 2]]])
+    }
+  })
+
+class TestDCRenames(test_engine.EngineTestCase):
+
+  def setUp(self):
+    super(TestDCRenames, self).setUp()
+
+    self.load_sample(testsamples.sample_students)
+
+    self.engine.apply_user_actions([useractions.from_repr(ua) for ua in (
+      # Add some irrelevant columns to the table Schools. These should never be renamed.
+      ["AddColumn", "Schools", "name2", {
+        "type": "Text"
+      }],
+      ["AddColumn", "Schools", "choice", {
+        "type": "Ref:Address"
+      }],
+      ["AddColumn", "Address", "rec", {
+        "type": "Text"
+      }],
+      # Add a dropdown condition formula to Schools.address (column #12).
+      ["ModifyColumn", "Schools", "address", {
+        "widgetOptions": json.dumps({
+          "dropdownCondition": {
+            "text": build_dc1_text("name", "city"),
+          }
+        }),
+      }],
+      # Create a similar column with an invalid dropdown condition formula.
+      # This formula should never be touched.
+      # This column will have the ID 25.
+      ["AddColumn", "Schools", "address2", {
+        "type": "Ref:Address",
+        "widgetOptions": json.dumps({
+          "dropdownCondition": {
+            "text": "+ 'New' in choice.city and $name == rec.name",
+          }
+        }),
+      }],
+      # And another similar column, but of type RefList.
+      # This column will have the ID 26.
+      ["AddColumn", "Schools", "addresses", {
+        "type": "RefList:Address",
+      }],
+      # AddColumn will not trigger parsing. We emulate a real user's action here by creating it first,
+      # then editing its widgetOptions.
+      ["ModifyColumn", "Schools", "addresses", {
+        "widgetOptions": json.dumps({
+          "dropdownCondition": {
+            "text": build_dc1_text("name", "city"),
+          }
+        }),
+      }],
+      # And another similar column, but of type ChoiceList.
+      # widgetOptions stay when the column type changes. We do our best to rename stuff in stray widgetOptions.
+      # This column will have the ID 27.
+      ["AddColumn", "Schools", "features", {
+        "type": "ChoiceList",
+      }],
+      ["ModifyColumn", "Schools", "features", {
+        "widgetOptions": json.dumps({
+          "dropdownCondition": {
+            "text": build_dc2_text("name", "address"),
+          }
+        }),
+      }],
+    )])
+
+    # This is what we'll have at the beginning, for later tests to refer to.
+    # Table Schools is 2.
+    self.assertTableData("_grist_Tables_column", cols="subset", rows="subset", data=[
+      ["id", "parentId", "colId", "widgetOptions"],
+      [12, 2, "address", build_dc1("name", "city")],
+      [26, 2, "addresses", build_dc1("name", "city")],
+      [27, 2, "features", build_dc2("name", "address")],
+    ])
+    self.assert_invalid_formula_untouched()
+
+  def assert_invalid_formula_untouched(self):
+    self.assertTableData("_grist_Tables_column", cols="subset", rows="subset", data=[
+      ["id", "parentId", "colId", "widgetOptions"],
+      [25, 2, "address2", json.dumps({
+        "dropdownCondition": {
+          "text": "+ 'New' in choice.city and $name == rec.name",
+        }
+      })]
+    ])
+
+  def test_referred_column_renames(self):
+    self.apply_user_action(["RenameColumn", "Address", "city", "area"])
+    self.assertTableData("_grist_Tables_column", cols="subset", rows="subset", data=[
+      ["id", "parentId", "colId", "widgetOptions"],
+      [12, 2, "address", build_dc1("name", "area")],
+      [26, 2, "addresses", build_dc1("name", "area")],
+      # Nothing should be renamed here, as only column renames in the table "Schools" are relevant.
+      [27, 2, "features", build_dc2("name", "address")],
+    ])
+    self.assert_invalid_formula_untouched()
+
+  def test_record_column_renames(self):
+    self.apply_user_action(["RenameColumn", "Schools", "name", "identifier"])
+    self.apply_user_action(["RenameColumn", "Schools", "address", "location"])
+    self.assertTableData("_grist_Tables_column", cols="subset", rows="subset", data=[
+      ["id", "parentId", "colId", "widgetOptions"],
+      # Side effect: "address" becomes "location".
+      [12, 2, "location", build_dc1("identifier", "city")],
+      [26, 2, "addresses", build_dc1("identifier", "city")],
+      # Now "$name" should become "$identifier", just like in Ref/RefList columns. Nothing else should change.
+      [27, 2, "features", build_dc2("identifier", "location")],
+    ])
+    self.assert_invalid_formula_untouched()
+
+  def test_multiple_renames(self):
+    # Put all renames together.
+    self.apply_user_action(["RenameColumn", "Address", "city", "area"])
+    self.apply_user_action(["RenameColumn", "Schools", "name", "identifier"])
+    self.assertTableData("_grist_Tables_column", cols="subset", rows="subset", data=[
+      ["id", "parentId", "colId", "widgetOptions"],
+      [12, 2, "address", build_dc1("identifier", "area")],
+      [26, 2, "addresses", build_dc1("identifier", "area")],
+      [27, 2, "features", build_dc2("identifier", "address")],
+    ])
+    self.assert_invalid_formula_untouched()
diff --git a/sandbox/grist/test_predicate_formula.py b/sandbox/grist/test_predicate_formula.py
index 34914c2e..4feb0ce1 100644
--- a/sandbox/grist/test_predicate_formula.py
+++ b/sandbox/grist/test_predicate_formula.py
@@ -139,14 +139,14 @@ class TestPredicateFormula(unittest.TestCase):
     self.assertRaises(SyntaxError, parse_predicate_formula, "def foo(): pass")
 
     # Unsupported node type
-    self.assertRaisesRegex(ValueError, r'Unsupported syntax', parse_predicate_formula, "max(rec)")
-    self.assertRaisesRegex(ValueError, r'Unsupported syntax', parse_predicate_formula, "user.id in {1, 2, 3}")
-    self.assertRaisesRegex(ValueError, r'Unsupported syntax', parse_predicate_formula, "1 if user.IsAnon else 2")
+    self.assertRaisesRegex(SyntaxError, r'Unsupported syntax', parse_predicate_formula, "max(rec)")
+    self.assertRaisesRegex(SyntaxError, r'Unsupported syntax', parse_predicate_formula, "user.id in {1, 2, 3}")
+    self.assertRaisesRegex(SyntaxError, r'Unsupported syntax', parse_predicate_formula, "1 if user.IsAnon else 2")
 
     # Unsupported operation
-    self.assertRaisesRegex(ValueError, r'Unsupported syntax', parse_predicate_formula, "1 | 2")
-    self.assertRaisesRegex(ValueError, r'Unsupported syntax', parse_predicate_formula, "1 << 2")
-    self.assertRaisesRegex(ValueError, r'Unsupported syntax', parse_predicate_formula, "~test")
+    self.assertRaisesRegex(SyntaxError, r'Unsupported syntax', parse_predicate_formula, "1 | 2")
+    self.assertRaisesRegex(SyntaxError, r'Unsupported syntax', parse_predicate_formula, "1 << 2")
+    self.assertRaisesRegex(SyntaxError, r'Unsupported syntax', parse_predicate_formula, "~test")
 
     # Syntax error
     self.assertRaises(SyntaxError, parse_predicate_formula, "[(]")
diff --git a/sandbox/grist/useractions.py b/sandbox/grist/useractions.py
index 95f5a49c..3c01b996 100644
--- a/sandbox/grist/useractions.py
+++ b/sandbox/grist/useractions.py
@@ -12,8 +12,9 @@ from six.moves import xrange
 import acl
 import depend
 import gencode
-from acl_formula import parse_acl_formulas
+from acl import parse_acl_formulas
 from dropdown_condition import parse_dropdown_conditions
+import dropdown_condition
 import actions
 import column
 import sort_specs
@@ -228,6 +229,12 @@ class UserActions(object):
     self._overrides = {key: method.__get__(self, UserActions)
                        for key, method in six.iteritems(_action_method_overrides)}
 
+  def get_docmodel(self):
+    """
+    Getter for the docmodel.
+    """
+    return self._docmodel
+
   @contextmanager
   def indirect_actions(self):
     """
@@ -635,7 +642,7 @@ class UserActions(object):
       if 'type' in values:
         self.doModifyColumn(col.tableId, col.colId, {'type': 'Int'})
 
-    make_acl_updates = acl.prepare_acl_table_renames(self._docmodel, self, table_renames)
+    make_acl_updates = acl.prepare_acl_table_renames(self, table_renames)
 
     # Collect all the table renames, and do the actual schema actions to apply them.
     for tbl, values in update_pairs:
@@ -691,6 +698,9 @@ class UserActions(object):
                if has_diff_value(values, 'colId', c.colId)}
 
     if renames:
+      # When a column rename has occurred, we need to update the corresponding references in
+      # formula, ACL rules and dropdown conditions.
+
       # Build up a dictionary mapping col_ref of each affected formula to the new formula text.
       formula_updates = self._prepare_formula_renames(renames)
 
@@ -698,6 +708,9 @@ class UserActions(object):
       for col_rec, new_formula in sorted(six.iteritems(formula_updates)):
         col_updates.setdefault(col_rec, {}).setdefault('formula', new_formula)
 
+      acl.perform_acl_rule_renames(self, renames)
+      dropdown_condition.perform_dropdown_condition_renames(self, renames)
+
     update_pairs = col_updates.items()
 
     # Disallow most changes to summary group-by columns, except to match the underlying column.
@@ -721,8 +734,6 @@ class UserActions(object):
           if not allowed_summary_change(key, value, expected):
             raise ValueError("Cannot modify summary group-by column '%s'" % col.colId)
 
-    make_acl_updates = acl.prepare_acl_col_renames(self._docmodel, self, renames)
-
     rename_summary_tables = set()
     for c, values in update_pairs:
       # Trigger ModifyColumn and RenameColumn as necessary
@@ -745,8 +756,6 @@ class UserActions(object):
       table = self._engine.tables[table_id]
       self._engine._update_table_model(table, table.user_table)
 
-    make_acl_updates()
-
     for table in rename_summary_tables:
       groupby_col_ids = [c.colId for c in table.columns if c.summarySourceCol]
       new_table_id = summary.encode_summary_table_name(table.summarySourceTable.tableId,
diff --git a/sandbox/grist/usertypes.py b/sandbox/grist/usertypes.py
index d617b927..d599a222 100644
--- a/sandbox/grist/usertypes.py
+++ b/sandbox/grist/usertypes.py
@@ -63,6 +63,13 @@ def formulaType(grist_type):
     return method
   return wrapper
 
+def get_referenced_table_id(col_type):
+  if col_type.startswith("Ref:"):
+    return col_type[4:]
+  if col_type.startswith("RefList:"):
+    return col_type[8:]
+  return None
+
 
 def ifError(value, value_if_error):
   """

From b6e48abf665dc29e395bebf944511064500bf234 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= <jordigh@octave.org>
Date: Thu, 11 Jul 2024 17:28:29 -0400
Subject: [PATCH 047/145] workflows: add a dummy ext to the stable build

See a437dfa28c7139bb436d8c1c88f674c9f16bb87a for details
---
 .github/workflows/docker.yml | 6 +++++-
 1 file changed, 5 insertions(+), 1 deletion(-)

diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
index f80c204c..38bf0e68 100644
--- a/.github/workflows/docker.yml
+++ b/.github/workflows/docker.yml
@@ -27,6 +27,10 @@ jobs:
       - name: Check out the repo
         uses: actions/checkout@v3
 
+      - name: Add a dummy ext/ directory
+        run:
+          mkdir ext && touch ext/dummy
+
       - name: Check out the ext/ directory
         if: matrix.image.name != 'grist-oss'
         run: buildtools/checkout-ext-directory.sh ${{ matrix.image.repo }}
@@ -65,5 +69,5 @@ jobs:
           tags: ${{ steps.meta.outputs.tags }}
           cache-from: type=gha
           cache-to: type=gha,mode=max
-          build-contexts: ${{ matrix.image.name != 'grist-oss' && 'ext=ext' || '' }}
+          build-contexts: ext=ext
 

From aafc9baac855f513c5cb4ba90b20f6ad18678d56 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= <jordigh@octave.org>
Date: Thu, 11 Jul 2024 17:25:32 -0400
Subject: [PATCH 048/145] workflows: use variable tags for the stable build

This will make it easier to do some testing while I make sure that
this build is correct.
---
 .github/workflows/docker.yml | 12 +++++++++++-
 1 file changed, 11 insertions(+), 1 deletion(-)

diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
index 38bf0e68..3c908014 100644
--- a/.github/workflows/docker.yml
+++ b/.github/workflows/docker.yml
@@ -5,6 +5,16 @@ on:
     types: [published]
   # Allows you to run this workflow manually from the Actions tab
   workflow_dispatch:
+    inputs:
+      tag:
+        description: "Tag for the resulting images"
+        type: string
+        required: True
+        default: 'stable'
+
+env:
+  TAG: ${{ inputs.tag || 'stable' }}
+  DOCKER_HUB_OWNER: ${{ vars.DOCKER_HUB_OWNER || github.repository_owner }}
 
 jobs:
   push_to_registry:
@@ -47,7 +57,7 @@ jobs:
             type=semver,pattern={{version}}
             type=semver,pattern={{major}}.{{minor}}
             type=semver,pattern={{major}}
-            stable
+            ${{ env.TAG }}
       - name: Set up QEMU
         uses: docker/setup-qemu-action@v1
 

From 6760416a249f77b57a54da410012ff7962c9e022 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= <jordigh@octave.org>
Date: Thu, 11 Jul 2024 17:27:13 -0400
Subject: [PATCH 049/145] workflows: don't build stable twice, just push it
 twice

This now matches the `docker_latest.yml` setup. No point building
grist-ee/grist twice.
---
 .github/workflows/docker.yml | 18 ++++++++++++++----
 1 file changed, 14 insertions(+), 4 deletions(-)

diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
index 3c908014..98587173 100644
--- a/.github/workflows/docker.yml
+++ b/.github/workflows/docker.yml
@@ -29,10 +29,6 @@ jobs:
             repo: "grist-core"
           - name: "grist"
             repo: "grist-ee"
-          # For now, we build it twice, with `grist-ee` being a
-          # backwards-compatible synoym for `grist`.
-          - name: "grist-ee"
-            repo: "grist-ee"
     steps:
       - name: Check out the repo
         uses: actions/checkout@v3
@@ -81,3 +77,17 @@ jobs:
           cache-to: type=gha,mode=max
           build-contexts: ext=ext
 
+      - name: Push Enterprise to Docker Hub
+        if: ${{ matrix.image.name == 'grist' }}
+        uses: docker/build-push-action@v2
+        with:
+          context: .
+          build-args: |
+            BASE_IMAGE=${{ env.DOCKER_HUB_OWNER }}/${{ matrix.image.name}}
+            BASE_VERSION=${{ env.TAG }}
+          file: ext/Dockerfile
+          platforms: ${{ env.PLATFORMS }}
+          push: true
+          tags: ${{ env.DOCKER_HUB_OWNER }}/grist-ee:${{ env.TAG }}
+          cache-from: type=gha
+          cache-to: type=gha,mode=max

From 5afd3cdf2ab14e9f1f60b75987a2815aa433b8e0 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= <jordigh@octave.org>
Date: Wed, 10 Jul 2024 17:18:46 -0400
Subject: [PATCH 050/145] v1.1.16

---
 package.json | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/package.json b/package.json
index 9cd1e9c3..708933bb 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "grist-core",
-  "version": "1.1.15",
+  "version": "1.1.16",
   "license": "Apache-2.0",
   "description": "Grist is the evolution of spreadsheets",
   "homepage": "https://github.com/gristlabs/grist-core",

From d922bdbb2d395c3d1658a7120d498793e035f1fc Mon Sep 17 00:00:00 2001
From: Dmitry <dsagal+git@gmail.com>
Date: Fri, 12 Jul 2024 14:21:11 -0400
Subject: [PATCH 051/145] Fix typo in README.md

---
 README.md | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/README.md b/README.md
index 92de74e8..17bf4de6 100644
--- a/README.md
+++ b/README.md
@@ -122,7 +122,7 @@ and running it on a public server in our
 
 The default Docker image is `gristlabs/grist`. This contains all of
 the standard Grist functionality, as well as extra source-available
-code for enterprise customers taken from the the
+code for enterprise customers taken from the
 [grist-ee](https://github.com/gristlabs/grist-ee) repository. This
 extra code is not under a free or open source license. By default,
 however, the code from the `grist-ee` repository is completely inert and

From 30eb956f5ce51afb0241b8e3e9c65dc2ec536444 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=D0=92=D0=BB=D0=B0=D0=B4=D0=B8=D0=BC=D0=B8=D1=80=20=D0=92?=
 <vakukh@gmail.com>
Date: Mon, 15 Jul 2024 17:03:07 +0000
Subject: [PATCH 052/145] Translated using Weblate (Russian)

Currently translated at 99.6% (1335 of 1340 strings)

Translation: Grist/client
Translate-URL: https://hosted.weblate.org/projects/grist/client/ru/
---
 static/locales/ru.client.json | 11 +++++++++--
 1 file changed, 9 insertions(+), 2 deletions(-)

diff --git a/static/locales/ru.client.json b/static/locales/ru.client.json
index 5fbdc3b4..8b0523eb 100644
--- a/static/locales/ru.client.json
+++ b/static/locales/ru.client.json
@@ -459,7 +459,10 @@
         "Python": "Python",
         "Python version used": "Python используемая версия",
         "Time Zone": "Часовой пояс",
-        "Try API calls from the browser": "Попробуйте вызовы API из браузера"
+        "Try API calls from the browser": "Попробуйте вызовы API из браузера",
+        "Formula times": "Время вычисления формулы",
+        "Only available to document editors": "Доступно только редакторам документов",
+        "Only available to document owners": "Доступно только владельцам документа"
     },
     "DocPageModel": {
         "Add Widget to Page": "Добавить виджет на страницу",
@@ -1550,7 +1553,11 @@
         "Grist allows different types of authentication to be configured, including SAML and OIDC.     We recommend enabling one of these if Grist is accessible over the network or being made available     to multiple people.": "Grist позволяет настраивать различные типы аутентификации, включая SAML и OIDC.     Мы рекомендуем включить один из них, если Grist доступен по сети или доступен     нескольким пользователям.",
         "Or, as a fallback, you can set: {{bootKey}} in the environment and visit: {{url}}": "Или, в качестве запасного варианта, вы можете установить: {{bootKey}} в окружающей среде и посетить: {{url}}",
         "Grist allows different types of authentication to be configured, including SAML and OIDC. We recommend enabling one of these if Grist is accessible over the network or being made available to multiple people.": "Grist позволяет настраивать различные типы аутентификации, включая SAML и OIDC. Мы рекомендуем включить один из них, если Grist доступен по сети или доступен нескольким пользователям.",
-        "You do not have access to the administrator panel.\nPlease log in as an administrator.": "У вас нет доступа к панели администратора.\nПожалуйста, войдите в систему как администратор."
+        "You do not have access to the administrator panel.\nPlease log in as an administrator.": "У вас нет доступа к панели администратора.\nПожалуйста, войдите в систему как администратор.",
+        "Key to sign sessions with": "Ключ для подписи сеансов с",
+        "Session Secret": "Session Secret",
+        "Grist signs user session cookies with a secret key. Please set this key via the environment variable GRIST_SESSION_SECRET. Grist falls back to a hard-coded default when it is not set. We may remove this notice in the future since session IDs have been updated to be inherently cryptographically secure.": "Grist подписывает файлы cookie сеанса пользователя секретным ключом. Установите этот ключ через переменную среды GRIST_SESSION_SECRET. Grist возвращается к жестко запрограммированному значению по умолчанию, если оно не установлено. Мы можем удалить это уведомление в будущем, поскольку идентификаторы сеансов, созданные начиная с версии 1.1.16, по своей сути криптографически безопасны.",
+        "Grist signs user session cookies with a secret key. Please set this key via the environment variable GRIST_SESSION_SECRET. Grist falls back to a hard-coded default when it is not set. We may remove this notice in the future as session IDs generated since v1.1.16 are inherently cryptographically secure.": "Grist подписывает файлы cookie сеанса пользователя секретным ключом. Установите этот ключ через переменную среды GRIST_SESSION_SECRET. Grist возвращается к жестко запрограммированному значению по умолчанию, если оно не установлено. Мы можем удалить это уведомление в будущем, поскольку идентификаторы сеансов, созданные начиная с версии 1.1.16, по своей сути криптографически безопасны."
     },
     "CreateTeamModal": {
         "Billing is not supported in grist-core": "Выставление счетов в grist-core не поддерживается",

From 73e022b0c5bfaa392b9a875e82df6c506832833a Mon Sep 17 00:00:00 2001
From: Libor Blaheta <blahetal@gmail.com>
Date: Mon, 15 Jul 2024 09:31:38 +0000
Subject: [PATCH 053/145] Translated using Weblate (Czech)

Currently translated at 7.9% (107 of 1340 strings)

Translation: Grist/client
Translate-URL: https://hosted.weblate.org/projects/grist/client/cs/
---
 static/locales/cs.client.json | 16 ++++++++--------
 1 file changed, 8 insertions(+), 8 deletions(-)

diff --git a/static/locales/cs.client.json b/static/locales/cs.client.json
index aecbf21f..c9261b6f 100644
--- a/static/locales/cs.client.json
+++ b/static/locales/cs.client.json
@@ -1,22 +1,22 @@
 {
     "AccessRules": {
-        "Add Column Rule": "Přidej Sloupcové Pravidlo",
+        "Add Column Rule": "Přidat pravidlo pro sloupec",
         "Lookup Column": "Vyhledávací Sloupec",
         "Enter Condition": "Napiš Podmínku",
         "Everyone Else": "Všichni Ostatní",
-        "Allow everyone to view Access Rules.": "Umožni všem zobrazit Přístupové Práva.",
+        "Allow everyone to view Access Rules.": "Umožnit všem zobrazit přístupová práva.",
         "Lookup Table": "Vyhledávací Tabulka",
-        "Add Table Rules": "Přidej Tabulkové Pravidlo",
+        "Add Table Rules": "Přidat pravidlo pro tabulku",
         "Invalid": "Neplatné",
         "Condition": "Podmínka",
         "Delete Table Rules": "Vymaž Tabulkové Pravidla",
         "Default Rules": "Základní Práva",
-        "Attribute name": "Jméno Atributu",
-        "Add User Attributes": "Přidej Uživatelské Atributy",
-        "Attribute to Look Up": "Atribut k Vyhledání",
+        "Attribute name": "Jméno atributu",
+        "Add User Attributes": "Přidat atribut pro uživatele",
+        "Attribute to Look Up": "Atribut k vyhledání",
         "Everyone": "Všichni",
         "Allow everyone to copy the entire document, or view it in full in fiddle mode.\nUseful for examples and templates, but not for sensitive data.": "Umožni všem kopírovat celý dokument, nebo zobrazit plně v \"fiddle\" režimu.\nUžiitečné pro ukázky a šablony, ale ne pro citlivá data.",
-        "Add Default Rule": "Přidej Základní Pravidlo",
+        "Add Default Rule": "Přidat výchozí pravidlo",
         "Checking...": "Kontroluji…",
         "Permissions": "Povolení",
         "Permission to view Access Rules": "Povolení na zobrazení Přistupových Pravidel",
@@ -37,7 +37,7 @@
         "Special Rules": "Speciální Pravidla"
     },
     "ACUserManager": {
-        "Invite new member": "Pozvi nového uživatele",
+        "Invite new member": "Pozvat nového uživatele",
         "We'll email an invite to {{email}}": "Zašleme pozvánku emailem na {{email}}",
         "Enter email address": "Napiš e-mailovou adresu"
     },

From b5e0e020ef138447874dc7a86413fe6e0322f822 Mon Sep 17 00:00:00 2001
From: George Gevoian <george@gevoian.com>
Date: Sun, 14 Jul 2024 22:37:10 -0400
Subject: [PATCH 054/145] (core) Disable SelectionSummary when diffing
 documents

Summary:
Cell values can't be summarized if they are diffs of two different
document versions. This was causing a JS error to be thrown when
comparing snapshots.

Test Plan: Browser test.

Reviewers: jarek

Reviewed By: jarek

Differential Revision: https://phab.getgrist.com/D4292
---
 app/client/components/GridView.js | 3 +--
 1 file changed, 1 insertion(+), 2 deletions(-)

diff --git a/app/client/components/GridView.js b/app/client/components/GridView.js
index e59577ec..8e3761ff 100644
--- a/app/client/components/GridView.js
+++ b/app/client/components/GridView.js
@@ -96,8 +96,7 @@ function GridView(gristDoc, viewSectionModel, isPreview = false) {
 
   this.cellSelector = selector.CellSelector.create(this, this);
 
-  if (!isPreview) {
-    // Disable summaries in import previews, for now.
+  if (!isPreview && !this.gristDoc.comparison) {
     this.selectionSummary = SelectionSummary.create(this,
       this.cellSelector, this.tableModel.tableData, this.sortedRows, this.viewSection.viewFields);
   }

From 2868ee2fa1ab21f96965f324a3b6e3721db119a7 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jaros=C5=82aw=20Sadzi=C5=84ski?=
 <jaroslaw.sadzinski@gmail.com>
Date: Tue, 16 Jul 2024 16:00:37 +0200
Subject: [PATCH 055/145] (core) Replacing python3 specifc code

Summary: Removing JSONDecodeError type from python code that is python3 only.

Test Plan: Existing

Reviewers: paulfitz, georgegevoian

Reviewed By: paulfitz, georgegevoian

Subscribers: paulfitz

Differential Revision: https://phab.getgrist.com/D4298
---
 sandbox/grist/dropdown_condition.py | 2 +-
 sandbox/grist/useractions.py        | 7 ++++---
 2 files changed, 5 insertions(+), 4 deletions(-)

diff --git a/sandbox/grist/dropdown_condition.py b/sandbox/grist/dropdown_condition.py
index c590db71..409486c3 100644
--- a/sandbox/grist/dropdown_condition.py
+++ b/sandbox/grist/dropdown_condition.py
@@ -35,7 +35,7 @@ def perform_dropdown_condition_renames(useractions, renames):
     try:
       widget_options = json.loads(col.widgetOptions)
       dc_formula = widget_options["dropdownCondition"]["text"]
-    except (json.JSONDecodeError, KeyError):
+    except (ValueError, KeyError):
       continue
 
     # Find out what table this column refers to and belongs to.
diff --git a/sandbox/grist/useractions.py b/sandbox/grist/useractions.py
index 3c01b996..19bfc1c0 100644
--- a/sandbox/grist/useractions.py
+++ b/sandbox/grist/useractions.py
@@ -708,9 +708,6 @@ class UserActions(object):
       for col_rec, new_formula in sorted(six.iteritems(formula_updates)):
         col_updates.setdefault(col_rec, {}).setdefault('formula', new_formula)
 
-      acl.perform_acl_rule_renames(self, renames)
-      dropdown_condition.perform_dropdown_condition_renames(self, renames)
-
     update_pairs = col_updates.items()
 
     # Disallow most changes to summary group-by columns, except to match the underlying column.
@@ -752,6 +749,10 @@ class UserActions(object):
 
     self.doBulkUpdateFromPairs(table_id, update_pairs)
 
+    if renames:
+      acl.perform_acl_rule_renames(self, renames)
+      dropdown_condition.perform_dropdown_condition_renames(self, renames)
+
     for table_id in rebuild_summary_tables:
       table = self._engine.tables[table_id]
       self._engine._update_table_model(table, table.user_table)

From 1d92e69c4342476fb66a335baf4e1ebf0ed732fb Mon Sep 17 00:00:00 2001
From: Spoffy <4805393+Spoffy@users.noreply.github.com>
Date: Tue, 16 Jul 2024 22:35:27 +0100
Subject: [PATCH 056/145] Adds a home directory for non-root docker user in
 container (#1109)

Fixes some edge cases where certain programs (e.g yarn) wouldn't work correctly due to HOME being set to a folder with bad permissions.
---
 sandbox/docker_entrypoint.sh | 8 ++++++++
 1 file changed, 8 insertions(+)

diff --git a/sandbox/docker_entrypoint.sh b/sandbox/docker_entrypoint.sh
index 7072e07e..0439739c 100755
--- a/sandbox/docker_entrypoint.sh
+++ b/sandbox/docker_entrypoint.sh
@@ -16,12 +16,20 @@ if [[ $current_user_id == 0 ]]; then
   # Make sure the target user owns everything that Grist needs write access to.
   find $write_dir ! -user "$target_user" -exec chown "$target_user" "{}" +
 
+  # Make a home directory for the target user, in case anything needs to access it.
+  export HOME="/grist_user_homes/${target_user}"
+  mkdir -p "$HOME"
+  chown -R "$target_user":"$target_group" "$HOME"
+
   # Restart as the target user, replacing the current process (replacement is needed for security).
   # Alternative tools to setpriv are: chroot, gosu.
   # Need to use `exec` to close the parent shell, to avoid vulnerabilities: https://github.com/tianon/gosu/issues/37
   exec setpriv --reuid "$target_user" --regid "$target_group" --init-groups /usr/bin/env bash "$0" "$@"
 fi
 
+# Printing the user helps with setting volume permissions.
+echo "Running Grist as user $(id -u) with primary group $(id -g)"
+
 # Validate that this user has access to the top level of each important directory.
 # There might be a benefit to testing individual files, but this is simpler as the dir may start empty.
 for dir in "${important_read_dirs[@]}"; do

From 063df752047efd0d7e3f025be9abf31ded36d844 Mon Sep 17 00:00:00 2001
From: Dmitry S <dsagal+git@gmail.com>
Date: Sat, 13 Jul 2024 01:27:54 -0400
Subject: [PATCH 057/145] (core) Forms improvements: mouse selection in
 firefox, focus, and styling

Summary:
- Remove unused Form file (Label.ts)
- Fix Firefox-specific bug in Forms, where mouse selection wasn't working in textarea.
- Focus and set cursor in textarea on click.
- Save on blur but only when focus stays within the Grist app, as for editing cells.
- Make paragraph margins of rendered form match those in the form editor.

Test Plan: Tested manually on Firefox and Chrome; relying on existing tests that nothing broke.

Reviewers: jarek

Reviewed By: jarek

Differential Revision: https://phab.getgrist.com/D4281
---
 app/client/components/Forms/Editor.ts    | 34 ++++------
 app/client/components/Forms/Label.ts     | 85 ------------------------
 app/client/components/Forms/Paragraph.ts | 28 ++++----
 app/client/components/Forms/elements.ts  |  1 -
 app/client/components/Forms/styles.ts    | 15 +++--
 app/client/ui/FormPage.ts                |  2 +-
 6 files changed, 40 insertions(+), 125 deletions(-)
 delete mode 100644 app/client/components/Forms/Label.ts

diff --git a/app/client/components/Forms/Editor.ts b/app/client/components/Forms/Editor.ts
index 9170995f..e59733d5 100644
--- a/app/client/components/Forms/Editor.ts
+++ b/app/client/components/Forms/Editor.ts
@@ -22,10 +22,6 @@ interface Props {
    * Actual element to put into the editor. This is the main content of the editor.
    */
   content: DomContents,
-  /**
-   * Click handler. If not provided, then clicking on the editor will select it.
-   */
-  click?: (ev: MouseEvent, box: BoxModel) => void,
   /**
    * Whether to show the remove button. Defaults to true.
    */
@@ -75,22 +71,6 @@ export function buildEditor(props: Props, ...args: IDomArgs<HTMLElement>) {
     style.cssRemoveButton.cls('-right', props.removePosition === 'right'),
   );
 
-  const onClick = (ev: MouseEvent) => {
-    // Only if the click was in this element.
-    const target = ev.target as HTMLElement;
-    if (!target.closest) { return; }
-    // Make sure that the closest editor is this one.
-    const closest = target.closest(`.${style.cssFieldEditor.className}`);
-    if (closest !== element) { return; }
-
-    ev.stopPropagation();
-    ev.preventDefault();
-    props.click?.(ev, props.box);
-
-    // Mark this box as selected.
-    box.view.selectedBox.set(box);
-  };
-
   const dragAbove = Observable.create(owner, false);
   const dragBelow = Observable.create(owner, false);
   const dragging = Observable.create(owner, false);
@@ -111,7 +91,10 @@ export function buildEditor(props: Props, ...args: IDomArgs<HTMLElement>) {
     testId('field-editor-selected', box.selected),
 
     // Select on click.
-    dom.on('click', onClick),
+    dom.on('click', (ev) => {
+      stopEvent(ev);
+      box.view.selectedBox.set(box);
+    }),
 
     // Attach context menu.
     buildMenu({
@@ -122,6 +105,15 @@ export function buildEditor(props: Props, ...args: IDomArgs<HTMLElement>) {
     // And now drag and drop support.
     {draggable: "true"},
 
+    // In Firefox, 'draggable' interferes with mouse selection in child input elements. Workaround
+    // is to turn off 'draggable' temporarily (see https://stackoverflow.com/q/21680363/328565).
+    dom.on('mousedown', (ev, elem) => {
+      const isInput = ["INPUT", "TEXTAREA"].includes((ev.target as Element)?.tagName);
+      // Turn off 'draggable' for inputs only, to support selection there; keep it on elsewhere.
+      elem.draggable = !isInput;
+    }),
+    dom.on('mouseup', (ev, elem) => { elem.draggable = true; }),
+
     // When started, we just put the box into the dataTransfer as a plain text.
     // TODO: this might be very sofisticated in the future.
     dom.on('dragstart', (ev) => {
diff --git a/app/client/components/Forms/Label.ts b/app/client/components/Forms/Label.ts
deleted file mode 100644
index 3233346c..00000000
--- a/app/client/components/Forms/Label.ts
+++ /dev/null
@@ -1,85 +0,0 @@
-import * as css from './styles';
-import {buildEditor} from 'app/client/components/Forms/Editor';
-import {BoxModel} from 'app/client/components/Forms/Model';
-import {stopEvent} from 'app/client/lib/domUtils';
-import {not} from 'app/common/gutil';
-import {Computed, dom, Observable} from 'grainjs';
-
-export class LabelModel extends BoxModel {
-  public edit = Observable.create(this, false);
-
-  protected defaultValue = '';
-
-  public render(): HTMLElement {
-    let element: HTMLTextAreaElement;
-    const text = this.prop('text', this.defaultValue) as Observable<string|undefined>;
-    const cssClass = this.prop('cssClass', '') as Observable<string>;
-    const editableText = Observable.create(this, text.get() || '');
-    const overlay = Computed.create(this, use => !use(this.edit));
-
-    this.autoDispose(text.addListener((v) => editableText.set(v || '')));
-
-    const save = (ok: boolean) => {
-      if (ok) {
-        text.set(editableText.get());
-        void this.parent?.save().catch(reportError);
-      } else {
-        editableText.set(text.get() || '');
-      }
-    };
-
-    const mode = (edit: boolean) => {
-      if (this.isDisposed() || this.edit.isDisposed()) { return; }
-      if (this.edit.get() === edit) { return; }
-      this.edit.set(edit);
-    };
-
-    return buildEditor(
-      {
-        box: this,
-        editMode: this.edit,
-        overlay,
-        click: (ev) => {
-          stopEvent(ev);
-          // If selected, then edit.
-          if (!this.selected.get()) { return; }
-          if (document.activeElement === element) { return; }
-          editableText.set(text.get() || '');
-          this.edit.set(true);
-          setTimeout(() => {
-            element.focus();
-            element.select();
-          }, 10);
-        },
-        content: element = css.cssEditableLabel(
-          editableText,
-          {onInput: true, autoGrow: true},
-          {placeholder: `Empty label`},
-          dom.on('click', ev => {
-            stopEvent(ev);
-          }),
-          // Styles saved (for titles and such)
-          css.cssEditableLabel.cls(use => `-${use(cssClass)}`),
-          // Disable editing if not in edit mode.
-          dom.boolAttr('readonly', not(this.edit)),
-          // Pass edit to css.
-          css.cssEditableLabel.cls('-edit', this.edit),
-          // Attach default save controls (Enter, Esc) and so on.
-          css.saveControls(this.edit, save),
-          // Turn off resizable for textarea.
-          dom.style('resize', 'none'),
-        ),
-      },
-      dom.onKeyDown({Enter$: (ev) => {
-        // If no in edit mode, change it.
-        if (!this.edit.get()) {
-          mode(true);
-          ev.stopPropagation();
-          ev.stopImmediatePropagation();
-          ev.preventDefault();
-          return;
-        }
-      }})
-    );
-  }
-}
diff --git a/app/client/components/Forms/Paragraph.ts b/app/client/components/Forms/Paragraph.ts
index f62eb65d..44ac1321 100644
--- a/app/client/components/Forms/Paragraph.ts
+++ b/app/client/components/Forms/Paragraph.ts
@@ -19,7 +19,6 @@ export class ParagraphModel extends BoxModel {
   public override render(): HTMLElement {
     const box = this;
     const editMode = box.edit;
-    let element: HTMLElement;
     const text = this.prop('text', this.defaultValue) as Observable<string|undefined>;
 
     // There is a spacial hack here. We might be created as a separator component, but the rendering
@@ -44,18 +43,21 @@ export class ParagraphModel extends BoxModel {
         this.cssClass ? dom.cls(this.cssClass, not(editMode)) : null,
         dom.maybe(editMode, () => {
           const draft = Observable.create(null, text.get() || '');
-          setTimeout(() => element?.focus(), 10);
-          return [
-            element = cssTextArea(draft, {autoGrow: true, onInput: true},
-              cssTextArea.cls('-edit', editMode),
-              css.saveControls(editMode, (ok) => {
-                if (ok && editMode.get()) {
-                  text.set(draft.get());
-                  this.save().catch(reportError);
-                }
-              })
-            ),
-          ];
+          return cssTextArea(draft, {autoGrow: true, onInput: true},
+            cssTextArea.cls('-edit', editMode),
+            (elem) => {
+              setTimeout(() => {
+                elem.focus();
+                elem.setSelectionRange(elem.value.length, elem.value.length);
+              }, 10);
+            },
+            css.saveControls(editMode, (ok) => {
+              if (ok && editMode.get()) {
+                text.set(draft.get());
+                this.save().catch(reportError);
+              }
+            })
+          );
         }),
       )
     });
diff --git a/app/client/components/Forms/elements.ts b/app/client/components/Forms/elements.ts
index c979e084..ede2b03c 100644
--- a/app/client/components/Forms/elements.ts
+++ b/app/client/components/Forms/elements.ts
@@ -13,7 +13,6 @@ export * from "./Section";
 export * from './Field';
 export * from './Columns';
 export * from './Submit';
-export * from './Label';
 
 export function defaultElement(type: FormLayoutNodeType): FormLayoutNode {
   switch(type) {
diff --git a/app/client/components/Forms/styles.ts b/app/client/components/Forms/styles.ts
index 1288d090..935269d2 100644
--- a/app/client/components/Forms/styles.ts
+++ b/app/client/components/Forms/styles.ts
@@ -1,3 +1,4 @@
+import type {App} from 'app/client/ui/App';
 import {textarea} from 'app/client/ui/inputs';
 import {sanitizeHTML} from 'app/client/ui/sanitizeHTML';
 import {basicButton, basicButtonLink, primaryButtonLink, textButton} from 'app/client/ui2018/buttons';
@@ -759,11 +760,17 @@ export function saveControls(editMode: Observable<boolean>, save: (ok: boolean)
         }
       }
     }),
-    dom.on('blur', (ev) => {
-      if (!editMode.isDisposed() && editMode.get()) {
-        save(true);
-        editMode.set(false);
+    dom.create((owner) => {
+      // Whenever focus returns to the Clipboard component, close the editor by saving the value.
+      function saveEdit() {
+        if (!editMode.isDisposed() && editMode.get()) {
+          save(true);
+          editMode.set(false);
+        }
       }
+      const app = (window as any).gristApp as App;
+      app.on('clipboard_focus', saveEdit);
+      owner.onDispose(() => app.off('clipboard_focus', saveEdit));
     }),
   ];
 }
diff --git a/app/client/ui/FormPage.ts b/app/client/ui/FormPage.ts
index ead02922..53d8de36 100644
--- a/app/client/ui/FormPage.ts
+++ b/app/client/ui/FormPage.ts
@@ -165,7 +165,7 @@ const cssFormContent = styled('form', `
     font-size: 10px;
   }
   & p {
-    margin: 0px;
+    margin: 0 0 10px 0;
   }
   & strong {
     font-weight: 600;

From f0d0a0729582206de054e360ea30b081124e7b90 Mon Sep 17 00:00:00 2001
From: Dmitry S <dsagal+git@gmail.com>
Date: Tue, 16 Jul 2024 23:43:53 -0400
Subject: [PATCH 058/145] (core) Implement PREVIOUS/NEXT/RANK and
 lookupRecords().find.* methods.

Summary:
- `lookupRecords()` now allows efficient search in sorted results, with
  the syntax  `lookupRecords(..., order_by="-Date").find.le($Date)`. This will find the record with the nearest date that's <= `$Date`.
- The `find.*` methods are `le`, `lt`, `ge`, `gt`, and `eq`. All have O(log N) performance.
- `PREVIOUS(rec, group_by=..., order_by=...)` finds the previous record to rec, according to `group_by` / `order_by`, in amortized O(log N) time. For example, `PREVIOUS(rec, group_by="Account", order_by="Date")`.
- `PREVIOUS(rec, order_by=None)` finds the previous record in the full table, sorted by the `manualSort` column, to match the order visible in the unsorted table.
- `NEXT(...)` is just like `PREVIOUS(...)` but finds the next record.
- `RANK(rec, group_by=..., order_by=..., order="asc")` returns the rank of the record within the group, starting with 1. Order can be `"asc"` (default) or `"desc"`.
- The `order_by` argument in `lookupRecords`, and the new functions now supports tuples, as well as the "-" prefix to reverse order, e.g. `("Category", "-Date")`.
- New functions are only available in Python3, for a minor reason (to support keyword-only arguments for `group_by` and `order_by`) and also as a nudge to Python2 users to update.

- Includes fixes for several situations related to lookups that used to cause quadratic complexity.

Test Plan:
- New performance check that sorted lookups don't add quadratic complexity.
- Tests added for lookup find.* methods, and for PREVIOUS/NEXT/RANK.
- Tests added that renaming columns updates `order_by` and `group_by` arguments, and attributes on results (e.g. `PREVIOUS(...).ColId`) appropriately.
- Python3 tests can now produce verbose output when VERBOSE=1 and -v are given.

Reviewers: jarek, georgegevoian

Reviewed By: jarek, georgegevoian

Subscribers: paulfitz, jarek

Differential Revision: https://phab.getgrist.com/D4265
---
 sandbox/grist/codebuilder.py             |  92 +++-
 sandbox/grist/fake_std_streams.py        |  13 +
 sandbox/grist/functions/__init__.py      |  16 +-
 sandbox/grist/functions/prevnext.py      |  61 +++
 sandbox/grist/functions/stats.py         |   5 -
 sandbox/grist/lookup.py                  | 454 ++++++++++++++------
 sandbox/grist/objtypes.py                |   7 +-
 sandbox/grist/records.py                 | 166 +++++++-
 sandbox/grist/sort_key.py                |  54 +++
 sandbox/grist/table.py                   | 125 ++++--
 sandbox/grist/test_engine.py             |  30 +-
 sandbox/grist/test_lookup_find.py        | 253 +++++++++++
 sandbox/grist/test_lookup_perf.py        | 115 +++++
 sandbox/grist/test_lookup_sort.py        | 514 +++++++++++++++++++++++
 sandbox/grist/test_lookups.py            |  40 +-
 sandbox/grist/test_prevnext.py           | 389 +++++++++++++++++
 sandbox/grist/test_sort_key.py           |  78 ++++
 sandbox/grist/test_summary_choicelist.py |  10 +-
 sandbox/grist/test_temp_rowids.py        |  21 +
 sandbox/grist/testutil.py                |  11 +
 sandbox/grist/twowaymap.py               |  22 +
 sandbox/grist/usertypes.py               |  14 +-
 22 files changed, 2291 insertions(+), 199 deletions(-)
 create mode 100644 sandbox/grist/functions/prevnext.py
 create mode 100644 sandbox/grist/sort_key.py
 create mode 100644 sandbox/grist/test_lookup_find.py
 create mode 100644 sandbox/grist/test_lookup_perf.py
 create mode 100644 sandbox/grist/test_lookup_sort.py
 create mode 100644 sandbox/grist/test_prevnext.py
 create mode 100644 sandbox/grist/test_sort_key.py

diff --git a/sandbox/grist/codebuilder.py b/sandbox/grist/codebuilder.py
index 6f608501..797df306 100644
--- a/sandbox/grist/codebuilder.py
+++ b/sandbox/grist/codebuilder.py
@@ -199,6 +199,8 @@ def infer(node):
 
 
 _lookup_method_names = ('lookupOne', 'lookupRecords')
+_prev_next_functions = ('PREVIOUS', 'NEXT', 'RANK')
+_lookup_find_methods = ('lt', 'le', 'gt', 'ge', 'eq', 'previous', 'next')
 
 def _is_table(node):
   """
@@ -323,6 +325,50 @@ class InferAllReference(InferenceTip):
     yield astroid.bases.Instance(infer(node.expr))
 
 
+class InferLookupFindResult(InferenceTip):
+  """
+  Inference helper to treat the return value of `Table.lookupRecords(...).find.lt(...)` as
+  returning instances of table `Table`.
+  """
+  node_class = astroid.nodes.Call
+
+  @classmethod
+  def filter(cls, node):
+    func = node.func
+    if isinstance(func, astroid.nodes.Attribute) and func.attrname in _lookup_find_methods:
+      p_expr = func.expr
+      if isinstance(p_expr, astroid.nodes.Attribute) and p_expr.attrname in ('find', '_find'):
+        obj = infer(p_expr.expr)
+        if isinstance(obj, astroid.bases.Instance) and _is_table(obj._proxied):
+          return True
+    return False
+
+  @classmethod
+  def infer(cls, node, context=None):
+    # A bit of fuzziness here: node.func.expr.expr is the result of lookupRecords(). It so happens
+    # that at the moment it is already of type Instance(table), as if a single record rather than
+    # a list, to support recognizing `.ColId` attributes. So we return the same type.
+    yield infer(node.func.expr.expr)
+
+
+class InferPrevNextResult(InferenceTip):
+  """
+  Inference helper to treat the return value of PREVIOUS(...) and NEXT(...) as returning instances
+  of table `Table`.
+  """
+  node_class = astroid.nodes.Call
+
+  @classmethod
+  def filter(cls, node):
+    return (isinstance(node.func, astroid.nodes.Name) and
+        node.func.name in _prev_next_functions and
+        node.args)
+
+  @classmethod
+  def infer(cls, node, context=None):
+    yield infer(node.args[0])
+
+
 class InferComprehensionBase(InferenceTip):
   node_class = astroid.nodes.AssignName
   reference_inference_class = None
@@ -397,7 +443,8 @@ def parse_grist_names(builder):
   code_text = builder.get_text()
 
   with use_inferences(InferReferenceColumn, InferReferenceFormula, InferLookupReference,
-                      InferLookupComprehension, InferAllReference, InferAllComprehension):
+                      InferLookupComprehension, InferAllReference, InferAllComprehension,
+                      InferLookupFindResult, InferPrevNextResult):
     atok = asttokens.ASTText(code_text, tree=astroid.builder.parse(code_text))
 
   def make_tuple(start, end, table_id, col_id):
@@ -413,6 +460,13 @@ def parse_grist_names(builder):
       return (in_value, in_patch.start, table_id, col_id)
     return None
 
+  # Helper for collecting column IDs mentioned in order_by/group_by parameters, so that
+  # those can be updated when a column is renamed.
+  def list_order_group_by_tuples(table_id, node):
+    for start, end, col_id in parse_order_group_by(atok, node):
+      if code_text[start:end] == col_id:
+        yield make_tuple(start, end, table_id, col_id)
+
   parsed_names = []
   for node in asttokens.util.walk(atok.tree, include_joined_str=True):
     if isinstance(node, astroid.nodes.Name):
@@ -430,21 +484,53 @@ def parse_grist_names(builder):
           start = end - len(node.attrname)
           if code_text[start:end] == node.attrname:
             parsed_names.append(make_tuple(start, end, cls.name, node.attrname))
+
     elif isinstance(node, astroid.nodes.Keyword):
       func = node.parent.func
       if isinstance(func, astroid.nodes.Attribute) and func.attrname in _lookup_method_names:
         obj = infer(func.expr)
         if _is_table(obj) and node.arg is not None:   # Skip **kwargs, which have arg value of None
+          table_id = obj.name
           start = atok.get_text_range(node)[0]
           end = start + len(node.arg)
-          if code_text[start:end] == node.arg:
-            parsed_names.append(make_tuple(start, end, obj.name, node.arg))
+          if node.arg == 'order_by':
+            # Rename values in 'order_by' arguments to lookup methods.
+            parsed_names.extend(list_order_group_by_tuples(table_id, node.value))
+          elif code_text[start:end] == node.arg:
+            parsed_names.append(make_tuple(start, end, table_id, node.arg))
+
+      elif (isinstance(func, astroid.nodes.Name)
+          # Rename values in 'order_by' and 'group_by' arguments to PREVIOUS() and NEXT().
+          and func.name in _prev_next_functions
+          and node.arg in ('order_by', 'group_by')
+          and node.parent.args):
+        obj = infer(node.parent.args[0])
+        if isinstance(obj, astroid.bases.Instance):
+          cls = obj._proxied
+          if _is_table(cls):
+            table_id = cls.name
+            parsed_names.extend(list_order_group_by_tuples(table_id, node.value))
 
   return [name for name in parsed_names if name]
 
 
 code_filename = "usercode"
 
+def parse_order_group_by(atok, node):
+  """
+  order_by and group_by parameters take the form of a column ID string, optionally prefixed by a
+  "-", or a tuple of them. We parse out the list of (start, end, col_id) tuples for each column ID
+  mentioned, to support automatic formula updates when a mentioned column is renamed.
+  """
+  if isinstance(node, astroid.nodes.Const):
+    if isinstance(node.value, six.string_types):
+      start, end = atok.get_text_range(node)
+      # Account for opening/closing quote, and optional leading "-".
+      return [(start + 2, end - 1, node.value[1:]) if node.value.startswith("-") else
+              (start + 1, end - 1, node.value)]
+  elif isinstance(node, astroid.nodes.Tuple):
+    return [t for e in node.elts for t in parse_order_group_by(atok, e)]
+  return []
 
 def save_to_linecache(source_code):
   """
diff --git a/sandbox/grist/fake_std_streams.py b/sandbox/grist/fake_std_streams.py
index e15a0a99..a33f6209 100644
--- a/sandbox/grist/fake_std_streams.py
+++ b/sandbox/grist/fake_std_streams.py
@@ -1,3 +1,4 @@
+import os
 import sys
 
 import six
@@ -16,3 +17,15 @@ class FakeStdStreams(object):
   def __exit__(self, exc_type, exc_val, exc_tb):
     sys.stdout = self._orig_stdout
     sys.stderr = self._orig_stderr
+
+
+if os.environ.get('VERBOSE'):
+  # Don't disable stdio streams if VERBOSE is on. This is helpful when debugging tests with
+  # logging messages or print() calls.
+  class DummyFakeStdStreams(object):
+    def __enter__(self):
+      pass
+
+    def __exit__(self, exc_type, exc_val, exc_tb):
+      pass
+  FakeStdStreams = DummyFakeStdStreams
diff --git a/sandbox/grist/functions/__init__.py b/sandbox/grist/functions/__init__.py
index 007dffe0..d86e2f20 100644
--- a/sandbox/grist/functions/__init__.py
+++ b/sandbox/grist/functions/__init__.py
@@ -1,4 +1,6 @@
-# pylint: disable=wildcard-import
+# pylint: disable=wildcard-import, unused-argument
+import six
+
 from .date import *
 from .info import *
 from .logical import *
@@ -8,5 +10,17 @@ from .stats import *
 from .text import *
 from .schedule import *
 
+if six.PY3:
+  # These new functions use Python3-specific syntax.
+  from .prevnext import *   # pylint: disable=import-error
+else:
+  # In Python2, only expose them to guide the user to upgrade.
+  def PREVIOUS(rec, group_by=None, order_by=None):
+    raise NotImplementedError("Update engine to Python3 to use PREVIOUS, NEXT, or RANK")
+  def NEXT(rec, group_by=None, order_by=None):
+    raise NotImplementedError("Update engine to Python3 to use PREVIOUS, NEXT, or RANK")
+  def RANK(rec, group_by=None, order_by=None, order="asc"):
+    raise NotImplementedError("Update engine to Python3 to use PREVIOUS, NEXT, or RANK")
+
 # Export all uppercase names, for use with `from functions import *`.
 __all__ = [k for k in dir() if not k.startswith('_') and k.isupper()]
diff --git a/sandbox/grist/functions/prevnext.py b/sandbox/grist/functions/prevnext.py
new file mode 100644
index 00000000..eb6bb123
--- /dev/null
+++ b/sandbox/grist/functions/prevnext.py
@@ -0,0 +1,61 @@
+def PREVIOUS(rec, *, group_by=(), order_by):
+  """
+  Finds the previous record in the table according to the order specified by `order_by`, and
+  grouping specified by `group_by`. Each of these arguments may be a column ID or a tuple of
+  column IDs, and `order_by` allows column IDs to be prefixed with "-" to reverse sort order.
+
+  For example,
+  - `PREVIOUS(rec, order_by="Date")` will return the previous record when the list of records is
+    sorted by the Date column.
+  - `PREVIOUS(rec, order_by="-Date")` will return the previous record when the list is sorted by
+    the Date column in descending order.
+  - `PREVIOUS(rec, group_by="Account", order_by="Date")` will return the previous record with the
+    same Account as `rec`, when records are filtered by the Account of `rec` and sorted by Date.
+
+  When multiple records have the same `order_by` values (e.g. the same Date in the examples above),
+  the order is determined by the relative position of rows in views. This is done internally by
+  falling back to the special column `manualSort` and the row ID column `id`.
+
+  Use `order_by=None` to find the previous record in an unsorted table (when rows may be
+  rearranged by dragging them manually). For example,
+  - `PREVIOUS(rec, order_by=None)` will return the previous record in the unsorted list of records.
+
+  You may specify multiple column IDs as a tuple, for both `group_by` and `order_by`. This can be
+  used to match views sorted by multiple columns. For example:
+  - `PREVIOUS(rec, group_by=("Account", "Year"), order_by=("Date", "-Amount"))`
+  """
+  return _sorted_lookup(rec, group_by=group_by, order_by=order_by)._find.previous(rec)
+
+def NEXT(rec, *, group_by=(), order_by):
+  """
+  Finds the next record in the table according to the order specified by `order_by`, and
+  grouping specified by `group_by`. See [`PREVIOUS`](#previous) for details.
+  """
+  return _sorted_lookup(rec, group_by=group_by, order_by=order_by)._find.next(rec)
+
+def RANK(rec, *, group_by=(), order_by, order="asc"):
+  """
+  Returns the rank (or position) of this record in the table according to the order specified by
+  `order_by`, and grouping specified by `group_by`. See [`PREVIOUS`](#previous) for details of
+  these parameters.
+
+  The `order` parameter may be "asc" (which is the default) or "desc".
+
+  When `order` is "asc" or omitted, the first record in the group in the sorted order would have
+  the rank of 1. When `order` is "desc", the last record in the sorted order would have the rank
+  of 1.
+
+  If there are multiple groups, there will be multiple records with the same rank. In particular,
+  each group will have a record with rank 1.
+
+  For example, `RANK(rec, group_by="Year", order_by="Score", order="desc")` will return the rank of
+  the current record (`rec`) among all the records in its table for the same year, ordered by
+  score.
+  """
+  return _sorted_lookup(rec, group_by=group_by, order_by=order_by)._find.rank(rec, order=order)
+
+
+def _sorted_lookup(rec, *, group_by, order_by):
+  if isinstance(group_by, str):
+    group_by = (group_by,)
+  return rec._table.lookup_records(**{c: getattr(rec, c) for c in group_by}, order_by=order_by)
diff --git a/sandbox/grist/functions/stats.py b/sandbox/grist/functions/stats.py
index 95bf279e..fcdb019a 100644
--- a/sandbox/grist/functions/stats.py
+++ b/sandbox/grist/functions/stats.py
@@ -495,11 +495,6 @@ def QUARTILE(data, quartile_number):
   """Returns a value nearest to a specified quartile of a dataset."""
   raise NotImplementedError()
 
-@unimplemented
-def RANK(value, data, is_ascending=None):
-  """Returns the rank of a specified value in a dataset."""
-  raise NotImplementedError()
-
 @unimplemented
 def RANK_AVG(value, data, is_ascending=None):
   """Returns the rank of a specified value in a dataset. If there is more than one entry of the same value in the dataset, the average rank of the entries will be returned."""
diff --git a/sandbox/grist/lookup.py b/sandbox/grist/lookup.py
index 9dd7a19b..ff501f21 100644
--- a/sandbox/grist/lookup.py
+++ b/sandbox/grist/lookup.py
@@ -1,3 +1,46 @@
+# Lookups are hard.
+#
+# Example to explain the relationship of various lookup helpers.
+# Let's say we have this formula (notation [People.Rate] means a column "Rate" in table "People").
+#     [People.Rate] = Rates.lookupRecords(Email=$Email, sort_by="Date")
+#
+# Conceptually, a good representation is to think of a helper table "UniqueRateEmails", which
+# contains a list of unique Email values in the table Rates. These are all the values that
+# lookupRecords() can find.
+#
+# So conceptually, it helps to imagine a table with the following columns:
+#     [UniqueRateEmails.Email] = each Email in Rates
+#     [UniqueRateEmails.lookedUpRates] = {r.id for r in Rates if r.Email == $Email}
+#       -- this is the set of row_ids of all Rates with the email of this UniqueRateEmails row.
+#     [UniqueRateEmails.lookedUpRatesSorted] = sorted($lookedUpRates)  # sorted by Date.
+#
+# We don't _actually_ create a helper table. (That would be a lot over overhead from all the extra
+# tracking for recalculations.)
+#
+# We have two helper columns in the Rates table (the one in which we are looking up):
+#     [Rate.#lookup#Email] (LookupMapColumn)
+#       This is responsible to know which Rate rows correspond to which Emails (using a
+#       SimpleLookupMapping helper). For any email, it can produce the set of row_ids of Rate
+#       records.
+#
+#       - It depends on [Rate.Email], so that changes to Email cause a recalculation.
+#       - When it gets recalculated, it
+#         - updates internal maps.
+#         - invalidates affected callers.
+#
+#     [Rate.#lookup#Email#Date] (SortedLookupMapColumn)
+#       For each set of Rate results, this maintains a list of Rate row_ids sorted by Date.
+#
+#       - It depends on [Rate.Date] so that changes to Date cause a recalculation.
+#       - When its do_lookup() is called, it creates
+#         - a dependency between the caller [People.Rate] and itself [Rate.#lookup#Email#Date]
+#           using a special _LookupRelation (which it keeps track of).
+#         - a dependency between the caller [People.Rate] and unsorted lookup [Rate.#lookup#Email]
+#           using another _LookupRelation (which [Rate.#lookup#Email] keeps track of).
+#       - When it gets recalculated, which means that order of the lookup result has changed:
+#         - it clears the cached sorted version of the lookup result
+#         - uses its _LookupRelations to invalidate affected callers.
+
 import itertools
 import logging
 from abc import abstractmethod
@@ -8,24 +51,29 @@ import column
 import depend
 import records
 import relation
+from sort_key import make_sort_key
 import twowaymap
+from twowaymap import LookupSet
 import usertypes
 from functions.lookup import _Contains
 
 log = logging.getLogger(__name__)
 
 
-def _extract(cell_value):
-  """
-  When cell_value is a Record, returns its rowId. Otherwise returns the value unchanged.
-  This is to allow lookups to work with reference columns.
-  """
-  if isinstance(cell_value, records.Record):
-    return cell_value._row_id
-  return cell_value
+class NoValueColumn(column.BaseColumn):
+  # Override various column methods, since (Sorted)LookupMapColumn doesn't care to store any
+  # values. To outside code, it looks like a column of None's.
+  def raw_get(self, row_id):
+    return None
+  def convert(self, value_to_convert):
+    return None
+  def get_cell_value(self, row_id, restore=False):
+    return None
+  def set(self, row_id, value):
+    pass
 
 
-class BaseLookupMapColumn(column.BaseColumn):
+class LookupMapColumn(NoValueColumn):
   """
   Conceptually a LookupMapColumn is associated with a table ("target table") and maintains for
   each row a key (which is a tuple of values from the named columns), which is fast to look up.
@@ -43,128 +91,208 @@ class BaseLookupMapColumn(column.BaseColumn):
   def __init__(self, table, col_id, col_ids_tuple):
     # Note that self._recalc_rec_method is passed in as the formula's "method".
     col_info = column.ColInfo(usertypes.Any(), is_formula=True, method=self._recalc_rec_method)
-    super(BaseLookupMapColumn, self).__init__(table, col_id, col_info)
+    super(LookupMapColumn, self).__init__(table, col_id, col_info)
 
-    self._col_ids_tuple = col_ids_tuple
-    self._engine = table._engine
+    # For performance, prefer SimpleLookupMapping when no CONTAINS is used in lookups.
+    if any(isinstance(col_id, _Contains) for col_id in col_ids_tuple):
+      self._mapping = ContainsLookupMapping(col_ids_tuple)
+    else:
+      self._mapping = SimpleLookupMapping(col_ids_tuple)
 
-    # Two-way map between rowIds of the target table (on the left) and key tuples (on the right).
-    # Multiple rows can naturally map to the same key.
-    # Multiple keys can map to the same row if CONTAINS() is used
-    # The map is populated by engine's _recompute when this
-    # node is brought up-to-date.
-    self._row_key_map = self._make_row_key_map()
-    self._engine.invalidate_column(self)
+    engine = table._engine
+    engine.invalidate_column(self)
+    self._relation_tracker = _RelationTracker(engine, self)
 
-    # Map of referring Node to _LookupRelation. Different tables may do lookups using this
-    # LookupMapColumn, and that creates a dependency from other Nodes to us, with a relation
-    # between referring rows and the lookup keys. This map stores these relations.
-    self._lookup_relations = {}
-
-  @abstractmethod
-  def _make_row_key_map(self):
-    raise NotImplementedError
-
-  @abstractmethod
-  def _recalc_rec_method(self, rec, table):
+  def _recalc_rec_method(self, rec, _table):
     """
     LookupMapColumn acts as a formula column, and this method is the "formula" called whenever
     a dependency changes. If LookupMapColumn indexes columns (A,B), then a change to A or B would
     cause the LookupMapColumn to be invalidated for the corresponding rows, and brought up to date
     during formula recomputation by calling this method. It shold take O(1) time per affected row.
     """
-    raise NotImplementedError
-
-  @abstractmethod
-  def _get_keys(self, target_row_id):
-    """
-    Get the keys associated with the given target row id.
-    """
-    raise NotImplementedError
-
-  def unset(self, row_id):
-    # This is called on record removal, and is necessary to deal with removed records.
-    old_keys = self._get_keys(row_id)
-    for old_key in old_keys:
-      self._row_key_map.remove(row_id, old_key)
-    self._invalidate_affected(old_keys)
-
-  def _invalidate_affected(self, affected_keys):
-    # For each known relation, figure out which referring rows are affected, and invalidate them.
-    # The engine will notice that there have been more invalidations, and recompute things again.
-    for node, rel in six.iteritems(self._lookup_relations):
-      affected_rows = rel.get_affected_rows_by_keys(affected_keys)
-      self._engine.invalidate_records(node.table_id, affected_rows, col_ids=(node.col_id,))
-
-  def _get_relation(self, referring_node):
-    """
-    Helper which returns an existing or new _LookupRelation object for the given referring Node.
-    """
-    rel = self._lookup_relations.get(referring_node)
-    if not rel:
-      rel = _LookupRelation(self, referring_node)
-      self._lookup_relations[referring_node] = rel
-    return rel
-
-  def _delete_relation(self, referring_node):
-    self._lookup_relations.pop(referring_node, None)
-    if not self._lookup_relations:
-      self._engine.mark_lookupmap_for_cleanup(self)
+    affected_keys = self._mapping.update_record(rec)
+    self._relation_tracker.invalidate_affected_keys(affected_keys)
 
   def _do_fast_empty_lookup(self):
     """
     Simplified version of do_lookup for a lookup column with no key columns
     to make Table._num_rows as fast as possible.
     """
-    return self._row_key_map.lookup_right((), default=())
+    return self._mapping.lookup_by_key((), default=())
+
+  def _do_fast_lookup(self, key):
+    key = tuple(_extract(val) for val in key)
+    return self._mapping.lookup_by_key(key, default=LookupSet())
+
+  @property
+  def sort_key(self):
+    return None
 
   def do_lookup(self, key):
     """
-    Looks up key in the lookup map and returns a tuple with two elements: the set of matching
-    records (as a set object, not ordered), and the Relation object for those records, relating
+    Looks up key in the lookup map and returns a tuple with two elements: the list of matching
+    records (sorted), and the Relation object for those records, relating
     the current frame to the returned records. Returns an empty set if no records match.
     """
     key = tuple(_extract(val) for val in key)
-    engine = self._engine
-    if engine._is_current_node_formula:
-      rel = self._get_relation(engine._current_node)
-      rel._add_lookup(engine._current_row_id, key)
-    else:
-      rel = None
-
-    # The _use_node call both brings LookupMapColumn up-to-date, and creates a dependency on it.
-    # Relation of None isn't valid, but it happens to be unused when there is no current_frame.
-    engine._use_node(self.node, rel)
-
-    row_ids = self._row_key_map.lookup_right(key, set())
+    row_ids, rel = self._do_lookup_with_sort(key, (), None)
     return row_ids, rel
 
-  # Override various column methods, since LookupMapColumn doesn't care to store any values. To
-  # outside code, it looks like a column of None's.
-  def raw_get(self, value):
-    return None
-  def convert(self, value):
-    return None
-  def get_cell_value(self, row_id):
-    return None
-  def set(self, row_id, value):
-    pass
+  def _do_lookup_with_sort(self, key, sort_spec, sort_key):
+    rel = self._relation_tracker.update_relation_from_current_node(key)
+    row_id_set = self._do_fast_lookup(key)
+    row_ids = row_id_set.sorted_versions.get(sort_spec)
+    if row_ids is None:
+      row_ids = sorted(row_id_set, key=sort_key)
+      row_id_set.sorted_versions[sort_spec] = row_ids
+    return row_ids, rel
 
-# For performance, prefer SimpleLookupMapColumn when no CONTAINS is used
-# in lookups, although the two implementations should be equivalent
-# See also table._add_update_summary_col
+  def _reset_sorted_versions(self, rec, sort_spec):
+    # For the lookup keys in rec, find the associated LookupSets, and clear the cached
+    # .sorted_versions entry for the given sort_spec. Used when only sort-by columns change.
+    # Returns the set of affected keys.
+    new_keys = set(self._mapping.get_new_keys_iter(rec))
+    for key in new_keys:
+      row_ids = self._mapping.lookup_by_key(key, default=LookupSet())
+      row_ids.sorted_versions.pop(sort_spec, None)
+    return new_keys
 
-class SimpleLookupMapColumn(BaseLookupMapColumn):
+  def unset(self, row_id):
+    # This is called on record removal, and is necessary to deal with removed records.
+    affected_keys = self._mapping.remove_row_id(row_id)
+    self._relation_tracker.invalidate_affected_keys(affected_keys)
+
+  def _get_keys(self, row_id):
+    # For _LookupRelation to know which keys are affected when the given looked-up row_id changes.
+    return self._mapping.get_mapped_keys(row_id)
+
+#----------------------------------------------------------------------
+
+class SortedLookupMapColumn(NoValueColumn):
+  """
+  A SortedLookupMapColumn is associated with a LookupMapColumn and a set of columns used for
+  sorting. It lives in the table containing the looked-up data. It is like a FormulaColumn in that
+  it has a method triggered for a record whenever any of the sort columns change for that record.
+
+  This method, in turn, invalidates lookups using the relations maintained by the LookupMapColumn.
+  """
+  def __init__(self, table, col_id, lookup_col, sort_spec):
+    # Before creating the helper column, check that all dependencies are actually valid col_ids.
+    sort_col_ids = [(c[1:] if c.startswith('-') else c) for c in sort_spec]
+
+    for c in sort_col_ids:
+      if not table.has_column(c):
+        raise KeyError("Table %s has no column %s" % (table.table_id, c))
+
+    # Note that different LookupSortHelperColumns may exist with the same sort_col_ids but
+    # different sort_keys because they could differ in order of columns and ASC/DESC flags.
+    col_info = column.ColInfo(usertypes.Any(), is_formula=True, method=self._recalc_rec_method)
+    super(SortedLookupMapColumn, self).__init__(table, col_id, col_info)
+    self._lookup_col = lookup_col
+
+    self._sort_spec = sort_spec
+    self._sort_col_ids = sort_col_ids
+    self._sort_key = make_sort_key(table, sort_spec)
+
+    self._engine = table._engine
+    self._engine.invalidate_column(self)
+    self._relation_tracker = _RelationTracker(self._engine, self)
+
+  @property
+  def sort_key(self):
+    return self._sort_key
+
+  def do_lookup(self, key):
+    """
+    Looks up key in the lookup map and returns a tuple with two elements: the list of matching
+    records (sorted), and the Relation object for those records, relating
+    the current frame to the returned records. Returns an empty set if no records match.
+    """
+    key = tuple(_extract(val) for val in key)
+    self._relation_tracker.update_relation_from_current_node(key)
+    row_ids, rel = self._lookup_col._do_lookup_with_sort(key, self._sort_spec, self._sort_key)
+    return row_ids, rel
+
+  def _recalc_rec_method(self, rec, _table):
+    # Create dependencies on all the sort columns.
+    for col_id in self._sort_col_ids:
+      getattr(rec, col_id)
+
+    affected_keys = self._lookup_col._reset_sorted_versions(rec, self._sort_spec)
+    self._relation_tracker.invalidate_affected_keys(affected_keys)
+
+  def _get_keys(self, row_id):
+    # For _LookupRelation to know which keys are affected when the given looked-up row_id changes.
+    return self._lookup_col._get_keys(row_id)
+
+#----------------------------------------------------------------------
+
+class BaseLookupMapping(object):
+  def __init__(self, col_ids_tuple):
+    self._col_ids_tuple = col_ids_tuple
+
+    # Two-way map between rowIds of the target table (on the left) and key tuples (on the right).
+    # Multiple rows can naturally map to the same key.
+    # A single row can map to multiple keys when CONTAINS() is used.
+    self._row_key_map = self._make_row_key_map()
+
+  @abstractmethod
   def _make_row_key_map(self):
-    return twowaymap.TwoWayMap(left=set, right="single")
+    raise NotImplementedError
 
-  def _recalc_rec_method(self, rec, table):
-    old_key = self._row_key_map.lookup_left(rec._row_id)
+  @abstractmethod
+  def get_mapped_keys(self, row_id):
+    """
+    Get the set of keys associated with the given target row id, as stored in our mapping.
+    """
+    raise NotImplementedError
 
+  @abstractmethod
+  def get_new_keys_iter(self, rec):
+    """
+    Returns an iterator over the current value of all keys represented by the given record.
+    Typically, it's just one key, but when list-type columns are involved, then could be several.
+    """
+    raise NotImplementedError
+
+  @abstractmethod
+  def update_record(self, rec):
+    """
+    Update the mapping to reflect the current value of all keys represented by the given record,
+    and return all the affected keys, i.e. the set of all the keys that changed (old and new).
+    """
+    raise NotImplementedError
+
+  def remove_row_id(self, row_id):
+    old_keys = self.get_mapped_keys(row_id)
+    for old_key in old_keys:
+      self._row_key_map.remove(row_id, old_key)
+    return old_keys
+
+  def lookup_by_key(self, key, default=None):
+    return self._row_key_map.lookup_right(key, default=default)
+
+
+class SimpleLookupMapping(BaseLookupMapping):
+  def _make_row_key_map(self):
+    return twowaymap.TwoWayMap(left=LookupSet, right="single")
+
+  def _get_mapped_key(self, row_id):
+    return self._row_key_map.lookup_left(row_id)
+
+  def get_mapped_keys(self, row_id):
+    return {self._get_mapped_key(row_id)}
+
+  def get_new_keys_iter(self, rec):
     # Note that getattr(rec, _col_id) is what creates the correct dependency, as well as ensures
     # that the columns used to index by are brought up-to-date (in case they are formula columns).
-    new_key = tuple(_extract(getattr(rec, _col_id)) for _col_id in self._col_ids_tuple)
+    return [tuple(_extract(getattr(rec, _col_id)) for _col_id in self._col_ids_tuple)]
 
+  def update_record(self, rec):
+    old_key = self._get_mapped_key(rec._row_id)
+    new_key = self.get_new_keys_iter(rec)[0]
+    if new_key == old_key:
+      return set()
     try:
       self._row_key_map.insert(rec._row_id, new_key)
     except TypeError:
@@ -172,18 +300,20 @@ class SimpleLookupMapColumn(BaseLookupMapColumn):
       self._row_key_map.remove(rec._row_id, old_key)
       new_key = None
 
-    # It's OK if None is one of the values, since None will just never be found as a key.
-    self._invalidate_affected({old_key, new_key})
-
-  def _get_keys(self, target_row_id):
-    return {self._row_key_map.lookup_left(target_row_id)}
+    # Both keys are affected when present.
+    return {k for k in (old_key, new_key) if k is not None}
 
 
-class ContainsLookupMapColumn(BaseLookupMapColumn):
+class ContainsLookupMapping(BaseLookupMapping):
   def _make_row_key_map(self):
-    return twowaymap.TwoWayMap(left=set, right=set)
+    return twowaymap.TwoWayMap(left=LookupSet, right=set)
 
-  def _recalc_rec_method(self, rec, table):
+  def get_mapped_keys(self, row_id):
+    # Need to copy the return value since it's the actual set
+    # stored in the map and may be modified
+    return set(self._row_key_map.lookup_left(row_id, ()))
+
+  def get_new_keys_iter(self, rec):
     # Create a key in the index for every combination of values in columns
     # looked up with CONTAINS()
     new_keys_groups = []
@@ -211,27 +341,79 @@ class ContainsLookupMapColumn(BaseLookupMapColumn):
 
       new_keys_groups.append([_extract(v) for v in group])
 
-    new_keys = set(itertools.product(*new_keys_groups))
+    return itertools.product(*new_keys_groups)
+
+  def update_record(self, rec):
+    new_keys = set(self.get_new_keys_iter(rec))
 
     row_id = rec._row_id
-    old_keys = self._get_keys(row_id)
+    old_keys = self.get_mapped_keys(row_id)
+
     for old_key in old_keys - new_keys:
       self._row_key_map.remove(row_id, old_key)
 
     for new_key in new_keys - old_keys:
       self._row_key_map.insert(row_id, new_key)
 
-    # Invalidate all keys which were either inserted or removed
-    self._invalidate_affected(new_keys ^ old_keys)
-
-  def _get_keys(self, target_row_id):
-    # Need to copy the return value since it's the actual set
-    # stored in the map and may be modified
-    return set(self._row_key_map.lookup_left(target_row_id, ()))
-
+    # Affected keys are those that were either newly inserted or newly removed.
+    return new_keys ^ old_keys
 
 #----------------------------------------------------------------------
 
+class _RelationTracker(object):
+  """
+  Helper used by (Sorted)LookupMapColumn to keep track of the _LookupRelations between referring
+  nodes and that column.
+  """
+  def __init__(self, engine, lookup_map):
+    self._engine = engine
+    self._lookup_map = lookup_map
+
+    # Map of referring Node to _LookupRelation. Different tables may do lookups using a
+    # (Sorted)LookupMapColumn, and that creates a dependency from other Nodes to us, with a
+    # relation between referring rows and the lookup keys. This map stores these relations.
+    self._lookup_relations = {}
+
+  def update_relation_from_current_node(self, key):
+    """
+    Looks up key in the lookup map and returns a tuple with two elements: the list of matching
+    records (sorted), and the Relation object for those records, relating
+    the current frame to the returned records. Returns an empty set if no records match.
+    """
+    engine = self._engine
+    if engine._is_current_node_formula:
+      rel = self._get_relation(engine._current_node)
+      rel._add_lookup(engine._current_row_id, key)
+    else:
+      rel = None
+
+    # The _use_node call brings the _lookup_map column up-to-date, and creates a dependency on it.
+    # Relation of None isn't valid, but it happens to be unused when there is no current_frame.
+    engine._use_node(self._lookup_map.node, rel)
+    return rel
+
+  def invalidate_affected_keys(self, affected_keys):
+    # For each known relation, figure out which referring rows are affected, and invalidate them.
+    # The engine will notice that there have been more invalidations, and recompute things again.
+    for rel in six.itervalues(self._lookup_relations):
+      rel.invalidate_affected_keys(affected_keys, self._engine)
+
+  def _get_relation(self, referring_node):
+    """
+    Helper which returns an existing or new _LookupRelation object for the given referring Node.
+    """
+    rel = self._lookup_relations.get(referring_node)
+    if not rel:
+      rel = _LookupRelation(self._lookup_map, self, referring_node)
+      self._lookup_relations[referring_node] = rel
+    return rel
+
+  def _delete_relation(self, referring_node):
+    self._lookup_relations.pop(referring_node, None)
+    if not self._lookup_relations:
+      self._engine.mark_lookupmap_for_cleanup(self._lookup_map)
+
+
 class _LookupRelation(relation.Relation):
   """
   _LookupRelation maintains a mapping between rows of a table doing a lookup to the rows getting
@@ -242,15 +424,21 @@ class _LookupRelation(relation.Relation):
   other code.
   """
 
-  def __init__(self, lookup_map, referring_node):
+  def __init__(self, lookup_map, relation_tracker, referring_node):
     super(_LookupRelation, self).__init__(referring_node.table_id, lookup_map.table_id)
     self._lookup_map = lookup_map
+    self._relation_tracker = relation_tracker
     self._referring_node = referring_node
 
     # Maps referring rows to keys, where multiple rows may map to the same key AND one row may
     # map to multiple keys (if a formula does multiple lookup calls).
     self._row_key_map = twowaymap.TwoWayMap(left=set, right=set)
 
+    # This is for an optimization. We may invalidate the same key many times (including O(N)
+    # times), which will lead to invalidating the same O(N) records over and over, resulting in
+    # O(N^2) work. By remembering the keys we invalidated, we can avoid that waste.
+    self._invalidated_keys_cache = set()
+
   def __str__(self):
     return "_LookupRelation(%s->%s)" % (self._referring_node, self.target_table)
 
@@ -266,6 +454,13 @@ class _LookupRelation(relation.Relation):
       set().union(*[self._lookup_map._get_keys(r) for r in target_row_ids])
     )
 
+  def invalidate_affected_keys(self, affected_keys, engine):
+    affected_rows = self.get_affected_rows_by_keys(affected_keys - self._invalidated_keys_cache)
+    if affected_rows:
+      node = self._referring_node
+      engine.invalidate_records(node.table_id, affected_rows, col_ids=(node.col_id,))
+      self._invalidated_keys_cache.update(affected_keys)
+
   def get_affected_rows_by_keys(self, keys):
     """
     This is used by LookupMapColumn to know which rows got affected when a target row changed to
@@ -283,6 +478,7 @@ class _LookupRelation(relation.Relation):
     process of computing the given referring_row_id.
     """
     self._row_key_map.insert(referring_row_id, key)
+    self._reset_invalidated_keys_cache()
 
   def reset_rows(self, referring_rows):
     """
@@ -295,6 +491,7 @@ class _LookupRelation(relation.Relation):
     else:
       for row_id in referring_rows:
         self._row_key_map.remove_left(row_id)
+    self._reset_invalidated_keys_cache()
 
   def reset_all(self):
     """
@@ -303,7 +500,15 @@ class _LookupRelation(relation.Relation):
     # In this case also, remove it from the LookupMapColumn. Once all relations are gone, the
     # lookup map can get cleaned up.
     self._row_key_map.clear()
-    self._lookup_map._delete_relation(self._referring_node)
+    self._relation_tracker._delete_relation(self._referring_node)
+    self._reset_invalidated_keys_cache()
+
+  def _reset_invalidated_keys_cache(self):
+    # When the invalidations take effect (i.e. invalidated columns get recomputed), the engine
+    # resets the relations for the affected rows. We use that, as well as any change to the
+    # relation, as a signal to clear _invalidated_keys_cache. Its purpose is only to serve while
+    # going down a helper (Sorted)LookupMapColumn.
+    self._invalidated_keys_cache.clear()
 
 
 def extract_column_id(c):
@@ -311,3 +516,12 @@ def extract_column_id(c):
     return c.value
   else:
     return c
+
+def _extract(cell_value):
+  """
+  When cell_value is a Record, returns its rowId. Otherwise returns the value unchanged.
+  This is to allow lookups to work with reference columns.
+  """
+  if isinstance(cell_value, records.Record):
+    return cell_value._row_id
+  return cell_value
diff --git a/sandbox/grist/objtypes.py b/sandbox/grist/objtypes.py
index 1c298f6f..a77b83b4 100644
--- a/sandbox/grist/objtypes.py
+++ b/sandbox/grist/objtypes.py
@@ -379,10 +379,11 @@ class RecordList(list):
   Just like list but allows setting custom attributes, which we use for remembering _group_by and
   _sort_by attributes when storing RecordSet as usertypes.ReferenceList type.
   """
-  def __init__(self, row_ids, group_by=None, sort_by=None):
+  def __init__(self, row_ids, group_by=None, sort_by=None, sort_key=None):
     list.__init__(self, row_ids)
-    self._group_by = group_by
-    self._sort_by = sort_by
+    self._group_by = group_by       # None or a tuple of col_ids
+    self._sort_by = sort_by         # None or a tuple of col_ids, optionally prefixed with "-"
+    self._sort_key = sort_key       # Comparator function (see sort_key.py)
 
   def __repr__(self):
     return "RecordList(%s, group_by=%r, sort_by=%r)" % (
diff --git a/sandbox/grist/records.py b/sandbox/grist/records.py
index e2b124a0..4a67ce23 100644
--- a/sandbox/grist/records.py
+++ b/sandbox/grist/records.py
@@ -3,7 +3,11 @@ Implements the base classes for Record and RecordSet objects used to represent r
 tables. Individual tables use derived versions of these, which add per-column properties.
 """
 
+from bisect import bisect_left, bisect_right
 import functools
+import sys
+
+import six
 
 @functools.total_ordering
 class Record(object):
@@ -134,14 +138,14 @@ class RecordSet(object):
   """
 
   # Slots are an optimization to avoid the need for a per-object __dict__.
-  __slots__ = ('_row_ids', '_source_relation', '_group_by', '_sort_by')
+  __slots__ = ('_row_ids', '_source_relation', '_group_by', '_sort_by', '_sort_key')
 
   # Per-table derived classes override this and set it to the appropriate Table object.
   _table = None
 
   # Methods should be named with a leading underscore to avoid interfering with access to
   # user-defined fields.
-  def __init__(self, row_ids, relation=None, group_by=None, sort_by=None):
+  def __init__(self, row_ids, relation=None, group_by=None, sort_by=None, sort_key=None):
     """
     group_by may be a dictionary mapping column names to values that are all the same for the given
     RecordSet. sort_by may be the column name used for sorting this record set. Both are set by
@@ -149,9 +153,10 @@ class RecordSet(object):
     """
     self._row_ids = row_ids
     self._source_relation = relation or self._table._identity_relation
-    # If row_ids is itself a RecordList, default to its _group_by and _sort_by properties.
+    # If row_ids is itself a RecordList, default to its _group_by, _sort_by, _sort_key properties.
     self._group_by = group_by or getattr(row_ids, '_group_by', None)
     self._sort_by = sort_by or getattr(row_ids, '_sort_by', None)
+    self._sort_key = sort_key or getattr(row_ids, '_sort_key', None)
 
   def __len__(self):
     return len(self._row_ids)
@@ -181,15 +186,13 @@ class RecordSet(object):
     return False
 
   def get_one(self):
-    if not self._row_ids:
-      # Default to the empty/sample record
-      row_id = 0
-    elif self._sort_by:
-      # Pick the first record in the sorted order
-      row_id = self._row_ids[0]
-    else:
-      # Pick the first record in the order of the underlying table, for backwards compatibility.
-      row_id = min(self._row_ids)
+    # Pick the first record in the sorted order, or empty/sample record for empty RecordSet
+    row_id = self._row_ids[0] if self._row_ids else 0
+    return self._table.Record(row_id, self._source_relation)
+
+  def __getitem__(self, index):
+    # Allows subscripting a RecordSet as r[0] or r[-1].
+    row_id = self._row_ids[index]
     return self._table.Record(row_id, self._source_relation)
 
   def __getattr__(self, name):
@@ -198,11 +201,20 @@ class RecordSet(object):
   def __repr__(self):
     return "%s[%s]" % (self._table.table_id, self._row_ids)
 
+  def _at(self, index):
+    """
+    Returns element of RecordSet at the given index when the index is valid and non-negative.
+    Otherwise returns the empty/sample record.
+    """
+    row_id = self._row_ids[index] if (0 <= index < len(self._row_ids)) else 0
+    return self._table.Record(row_id, self._source_relation)
+
   def _clone_with_relation(self, src_relation):
     return self._table.RecordSet(self._row_ids,
                                  relation=src_relation.compose(self._source_relation),
                                  group_by=self._group_by,
-                                 sort_by=self._sort_by)
+                                 sort_by=self._sort_by,
+                                 sort_key=self._sort_key)
 
   def _get_encodable_row_ids(self):
     """
@@ -214,6 +226,134 @@ class RecordSet(object):
     else:
       return list(self._row_ids)
 
+  def _get_sort_key(self):
+    if not self._sort_key:
+      if self._sort_by:
+        raise ValueError("Sorted by %s but no sort_key" % (self._sort_by,))
+      raise ValueError("Can only use 'find' methods in a sorted reference list")
+    return self._sort_key
+
+  def _to_local_row_id(self, item):
+    if isinstance(item, int):
+      return item
+    if isinstance(item, Record) and item._table == self._table:
+      return int(item)
+    raise ValueError("unexpected search item")    # Need better error
+
+  @property
+  def find(self):
+    """
+    A set of methods for finding values in sorted set of records. For example:
+    ```
+    Transactions.lookupRecords(..., sort_by="Date").find.lt($Date)
+    Table.lookupRecords(..., sort_by=("Foo", "Bar")).find.le(foo, bar)
+    ```
+
+    If the `find` method is shadowed by a same-named user column, you may use `_find` instead.
+
+    The methods available are:
+
+    - `lt`: (less than) find nearest record with sort values < the given values
+    - `le`: (less than or equal to) find nearest record with sort values <= the given values
+    - `gt`: (greater than) find nearest record with sort values > the given values
+    - `ge`: (greater than or equal to) find nearest record with sort values >= the given values
+    - `eq`: (equal to) find nearest record with sort values == the given values
+
+    Example from https://templates.getgrist.com/5pHLanQNThxk/Payroll. Each person has a history of
+    pay rates, in the Rates table. To find a rate applicable on a certain date, here is how you
+    can do it old-style:
+    ```
+    # Get all the rates for the Person and Role in this row.
+    rates = Rates.lookupRecords(Person=$Person, Role=$Role)
+
+    # Pick out only those rates whose Rate_Start is on or before this row's Date.
+    past_rates = [r for r in rates if r.Rate_Start <= $Date]
+
+    # Select the latest of past_rates, i.e. maximum by Rate_Start.
+    rate = max(past_rates, key=lambda r: r.Rate_Start)
+
+    # Return the Hourly_Rate from the relevant Rates record.
+    return rate.Hourly_Rate
+    ```
+
+    With the new methods, it is much simpler:
+    ```
+    rate = Rates.lookupRecords(Person=$Person, Role=$Role, sort_by="Rate_Start").find.le($Date)
+    return rate.Hourly_Rate
+    ```
+
+    Note that this is also much faster when there are many rates for the same Person and Role.
+    """
+    return FindOps(self)
+
+  @property
+  def _find(self):
+    return FindOps(self)
+
+  def _find_eq(self, *values):
+    found = self._bisect_find(bisect_left, 0, _min_row_id, values)
+    if found:
+      # 'found' means that we found a row that's greater-than-or-equal-to the values we are
+      # looking for. To check if the row is actually "equal", it remains to check if it is stictly
+      # greater than the passed-in values.
+      key = self._get_sort_key()
+      if key(found._row_id, values) < key(found._row_id):
+        return self._table.Record(0, self._source_relation)
+    return found
+
+  def _bisect_index(self, bisect_func, search_row_id, search_values=None):
+    key = self._get_sort_key()
+    # Note that 'key' argument is only available from Python 3.10.
+    return bisect_func(self._row_ids, key(search_row_id, search_values), key=key)
+
+  def _bisect_find(self, bisect_func, shift, search_row_id, search_values=None):
+    i = self._bisect_index(bisect_func, search_row_id, search_values=search_values)
+    return self._at(i + shift)
+
+_min_row_id = -sys.float_info.max
+_max_row_id = sys.float_info.max
+
+if six.PY3:
+  class FindOps(object):
+    def __init__(self, record_set):
+      self._rset = record_set
+
+    def previous(self, row):
+      row_id = self._rset._to_local_row_id(row)
+      return self._rset._bisect_find(bisect_left, -1, row_id)
+
+    def next(self, row):
+      row_id = self._rset._to_local_row_id(row)
+      return self._rset._bisect_find(bisect_right, 0, row_id)
+
+    def rank(self, row, order="asc"):
+      row_id = self._rset._to_local_row_id(row)
+      index = self._rset._bisect_index(bisect_left, row_id)
+      if order == "asc":
+        return index + 1
+      elif order == "desc":
+        return len(self._rset) - index
+      else:
+        raise ValueError("The 'order' parameter must be \"asc\" (default) or \"desc\"")
+
+    def lt(self, *values):
+      return self._rset._bisect_find(bisect_left, -1, _min_row_id, values)
+
+    def le(self, *values):
+      return self._rset._bisect_find(bisect_right, -1, _max_row_id, values)
+
+    def gt(self, *values):
+      return self._rset._bisect_find(bisect_right, 0, _max_row_id, values)
+
+    def ge(self, *values):
+      return self._rset._bisect_find(bisect_left, 0, _min_row_id, values)
+
+    def eq(self, *values):
+      return self._rset._find_eq(*values)
+else:
+  class FindOps(object):
+    def __init__(self, record_set):
+      raise NotImplementedError("Update engine to Python3 to use lookupRecords().find")
 
 
 def adjust_record(relation, value):
diff --git a/sandbox/grist/sort_key.py b/sandbox/grist/sort_key.py
new file mode 100644
index 00000000..249ec4f3
--- /dev/null
+++ b/sandbox/grist/sort_key.py
@@ -0,0 +1,54 @@
+from numbers import Number
+
+def make_sort_key(table, sort_spec):
+  """
+  table: Table object from table.py
+  sort_spec: tuple of column IDs, optionally prefixed by '-' to invert the sort order.
+
+  Returns a key class for comparing row_ids, i.e. with the returned SortKey, the expression
+  SortKey(r1) < SortKey(r2) is true iff r1 comes before r2 according to sort_spec.
+
+  The returned SortKey also allows comparing values that aren't in the table:
+  SortKey(row_id, (v1, v2, ...)) will act as if the values of the columns mentioned in
+  sort_spec are v1, v2, etc.
+  """
+  col_sort_spec = []
+  for col_spec in sort_spec:
+    col_id, sign = (col_spec[1:], -1) if col_spec.startswith('-') else (col_spec, 1)
+    col_obj = table.get_column(col_id)
+    col_sort_spec.append((col_obj, sign))
+
+  class SortKey(object):
+    __slots__ = ("row_id", "values")
+
+    def __init__(self, row_id, values=None):
+      # When values are provided, row_id is not used for access but is used for comparison, so
+      # must still be comparable to any valid row_id (e.g. must not be None). We use
+      # +-sys.float_info.max in records.py for this.
+      self.row_id = row_id
+      self.values = values or tuple(c.get_cell_value(row_id) for (c, _) in col_sort_spec)
+
+    def __lt__(self, other):
+      for (a, b, (col_obj, sign)) in zip(self.values, other.values, col_sort_spec):
+        try:
+          if a < b:
+            return sign == 1
+          if b < a:
+            return sign == -1
+        except TypeError:
+          # Use fallback values to maintain order similar to Python2 (this matches the fallback
+          # logic in SafeSortKey in column.py).
+          # - None is less than everything else
+          # - Numbers are less than other types
+          # - Other types are ordered by type name
+          af = ( (0 if a is None else 1), (0 if isinstance(a, Number) else 1), type(a).__name__ )
+          bf = ( (0 if b is None else 1), (0 if isinstance(b, Number) else 1), type(b).__name__ )
+          if af < bf:
+            return sign == 1
+          if bf < af:
+            return sign == -1
+
+      # Fallback order is by ascending row_id.
+      return self.row_id < other.row_id
+
+  return SortKey
diff --git a/sandbox/grist/table.py b/sandbox/grist/table.py
index b582ab9d..94eabeaa 100644
--- a/sandbox/grist/table.py
+++ b/sandbox/grist/table.py
@@ -69,18 +69,32 @@ class UserTable(object):
     most commonly a field in the current row (e.g. `$SomeField`) or a constant (e.g. a quoted string
     like `"Some Value"`) (examples below).
 
-    You may set the optional `sort_by` parameter to the column ID by which to sort multiple matching
-    results, to determine which of them is returned. You can prefix the column ID with "-" to
-    reverse the order.
-
     For example:
     ```
     People.lookupRecords(Email=$Work_Email)
     People.lookupRecords(First_Name="George", Last_Name="Washington")
-    People.lookupRecords(Last_Name="Johnson", sort_by="First_Name")
-    Orders.lookupRecords(Customer=$id, sort_by="-OrderDate")
     ```
 
+    You may set the optional `order_by` parameter to the column ID by which to sort the results.
+    You can prefix the column ID with "-" to reverse the order. You can also specify multiple
+    column IDs as a tuple (e.g. `order_by=("Account", "-Date")`).
+
+    For example:
+    ```
+    Transactions.lookupRecords(Account=$Account, order_by="Date")
+    Transactions.lookupRecords(Account=$Account, order_by="-Date")
+    Transactions.lookupRecords(Active=True, order_by=("Account", "-Date"))
+    ```
+
+    For records with equal `order_by` fields, the results are sorted according to how they appear
+    in views (which is determined by the special `manualSort` column). You may set `order_by=None`
+    to match the order of records in unsorted views.
+
+    By default, with no `order_by`, records are sorted by row ID, as if with `order_by="id"`.
+
+    For backward compatibility, `sort_by` may be used instead of `order_by`, but only allows a
+    single field, and falls back to row ID (rather than `manualSort`).
+
     See [RecordSet](#recordset) for useful properties offered by the returned object.
 
     See [CONTAINS](#contains) for an example utilizing `UserTable.lookupRecords` to find records
@@ -92,27 +106,35 @@ class UserTable(object):
     return self.table.lookup_records(**field_value_pairs)
 
   def lookupOne(self, **field_value_pairs):
+    # pylint: disable=line-too-long
     """
     Name: lookupOne
     Usage: UserTable.__lookupOne__(Field_In_Lookup_Table=value, ...)
     Returns a [Record](#record) matching the given field=value arguments. The value may be any
     expression,
     most commonly a field in the current row (e.g. `$SomeField`) or a constant (e.g. a quoted string
-    like `"Some Value"`). If multiple records are found, the first match is returned.
-
-    You may set the optional `sort_by` parameter to the column ID by which to sort multiple matching
-    results, to determine which of them is returned. You can prefix the column ID with "-" to
-    reverse the order.
+    like `"Some Value"`).
 
     For example:
     ```
     People.lookupOne(First_Name="Lewis", Last_Name="Carroll")
     People.lookupOne(Email=$Work_Email)
-    Tickets.lookupOne(Person=$id, sort_by="Date")   # Find the first ticket for the person
-    Tickets.lookupOne(Person=$id, sort_by="-Date")  # Find the last ticket for the person
     ```
 
     Learn more about [lookupOne](references-lookups.md#lookupone).
+
+    If multiple records are found, the first match is returned. You may set the optional `order_by`
+    parameter to the column ID by which to sort the matches, to determine which of them is
+    returned as the first one. By default, the record with the lowest row ID is returned.
+
+    See [`lookupRecords`](#lookupRecords) for details of all available options and behavior of
+    `order_by` (and of its legacy alternative, `sort_by`).
+
+    For example:
+    ```
+    Tasks.lookupOne(Project=$id, order_by="Priority")  # Returns the Task with the smallest Priority.
+    Rates.lookupOne(Person=$id, order_by="-Date")      # Returns the Rate with the latest Date.
+    ```
     """
     return self.table.lookup_one_record(**field_value_pairs)
 
@@ -176,7 +198,7 @@ class Table(object):
       self._id_column = id_column
 
     def __contains__(self, row_id):
-      return row_id < self._id_column.size() and self._id_column.raw_get(row_id) > 0
+      return 0 < row_id < self._id_column.size() and self._id_column.raw_get(row_id) > 0
 
     def __iter__(self):
       for row_id in xrange(self._id_column.size()):
@@ -500,6 +522,7 @@ class Table(object):
     """
     # The tuple of keys used determines the LookupMap we need.
     sort_by = kwargs.pop('sort_by', None)
+    order_by = kwargs.pop('order_by', 'id')   # For backward compatibility
     key = []
     col_ids = []
     for col_id in sorted(kwargs):
@@ -520,21 +543,15 @@ class Table(object):
     key = tuple(key)
 
     lookup_map = self._get_lookup_map(col_ids)
-    row_id_set, rel = lookup_map.do_lookup(key)
-    if sort_by:
-      if not isinstance(sort_by, six.string_types):
-        raise TypeError("sort_by must be a column ID (string)")
-      reverse = sort_by.startswith("-")
-      sort_col = sort_by.lstrip("-")
-      sort_col_obj = self.all_columns[sort_col]
-      row_ids = sorted(
-        row_id_set,
-        key=lambda r: column.SafeSortKey(self._get_col_obj_value(sort_col_obj, r, rel)),
-        reverse=reverse,
-      )
+    sort_spec = make_sort_spec(order_by, sort_by, self.has_column('manualSort'))
+    if sort_spec:
+      sorted_lookup_map = self._get_sorted_lookup_map(lookup_map, sort_spec)
     else:
-      row_ids = sorted(row_id_set)
-    return self.RecordSet(row_ids, rel, group_by=kwargs, sort_by=sort_by)
+      sorted_lookup_map = lookup_map
+
+    row_ids, rel = sorted_lookup_map.do_lookup(key)
+    return self.RecordSet(row_ids, rel, group_by=kwargs, sort_by=sort_by,
+        sort_key=sorted_lookup_map.sort_key)
 
   def lookup_one_record(self, **kwargs):
     return self.lookup_records(**kwargs).get_one()
@@ -555,14 +572,19 @@ class Table(object):
         c = lookup.extract_column_id(c)
         if not self.has_column(c):
           raise KeyError("Table %s has no column %s" % (self.table_id, c))
-      if any(isinstance(col_id, lookup._Contains) for col_id in col_ids_tuple):
-        column_class = lookup.ContainsLookupMapColumn
-      else:
-        column_class = lookup.SimpleLookupMapColumn
-      lmap = column_class(self, lookup_col_id, col_ids_tuple)
+      lmap = lookup.LookupMapColumn(self, lookup_col_id, col_ids_tuple)
       self._add_special_col(lmap)
     return lmap
 
+  def _get_sorted_lookup_map(self, lookup_map, sort_spec):
+    helper_col_id = lookup_map.col_id + "#" + ":".join(sort_spec)
+    # Find or create a helper col for the given sort_spec.
+    helper_col = self._special_cols.get(helper_col_id)
+    if not helper_col:
+      helper_col = lookup.SortedLookupMapColumn(self, helper_col_id, lookup_map, sort_spec)
+      self._add_special_col(helper_col)
+    return helper_col
+
   def delete_column(self, col_obj):
     assert col_obj.table_id == self.table_id
     self._special_cols.pop(col_obj.col_id, None)
@@ -719,7 +741,40 @@ class Table(object):
     setattr(self.RecordSet, col_obj.col_id, recordset_field)
 
   def _remove_field_from_record_classes(self, col_id):
-    if hasattr(self.Record, col_id):
+    # Check if col_id is in the immediate dictionary of self.Record[Set]; if missing, or inherited
+    # from the base class (e.g. "find"), there is nothing to delete.
+    if col_id in self.Record.__dict__:
       delattr(self.Record, col_id)
-    if hasattr(self.RecordSet, col_id):
+    if col_id in self.RecordSet.__dict__:
       delattr(self.RecordSet, col_id)
+
+
+def make_sort_spec(order_by, sort_by, has_manual_sort):
+  # Note that rowId is always an automatic fallback.
+  if sort_by:
+    if not isinstance(sort_by, six.string_types):
+      # pylint: disable=line-too-long
+      raise TypeError("sort_by must be a string column ID, with optional '-'; use order_by for tuples")
+    # No fallback to 'manualSort' here, for backward compatibility.
+    return (sort_by,)
+
+  if not isinstance(order_by, tuple):
+    # Suppot None and single-string specs (for a single column)
+    if isinstance(order_by, six.string_types):
+      order_by = (order_by,)
+    elif order_by is None:
+      order_by = ()
+    else:
+      raise TypeError("order_by must be a string column ID, with optional '-', or a tuple of them")
+
+  # Check if 'id' is mentioned explicitly. If so, then no fallback to 'manualSort', or anything
+  # else, since row IDs are unique. Also, drop the 'id' column itself because the row ID fallback
+  # is mandatory and automatic.
+  if 'id' in order_by:
+    return order_by[:order_by.index('id')]
+
+  # Fall back to manualSort, but only if it exists in the table and not yet mentioned in order_by.
+  if has_manual_sort and 'manualSort' not in order_by:
+    return order_by + ('manualSort',)
+
+  return order_by
diff --git a/sandbox/grist/test_engine.py b/sandbox/grist/test_engine.py
index ae3a5756..71ddf54d 100644
--- a/sandbox/grist/test_engine.py
+++ b/sandbox/grist/test_engine.py
@@ -2,6 +2,7 @@ import difflib
 import functools
 import json
 import logging
+import os
 import sys
 import unittest
 from collections import namedtuple
@@ -39,7 +40,7 @@ class EngineTestCase(unittest.TestCase):
   @classmethod
   def setUpClass(cls):
     cls._orig_log_level = logging.root.level
-    logging.root.setLevel(logging.WARNING)
+    logging.root.setLevel(logging.DEBUG if os.environ.get('VERBOSE') else logging.WARNING)
 
   @classmethod
   def tearDownClass(cls):
@@ -58,7 +59,8 @@ class EngineTestCase(unittest.TestCase):
     def trace_call(col_obj, _rec):
       # Ignore formulas in metadata tables for simplicity. Such formulas are mostly private, and
       # it would be annoying to fix tests every time we change them.
-      if not col_obj.table_id.startswith("_grist_"):
+      # Also ignore negative row_ids, used as extra dependency nodes in lookups.
+      if not col_obj.table_id.startswith("_grist_") and _rec._row_id >= 0:
         tmap = self.call_counts.setdefault(col_obj.table_id, {})
         tmap[col_obj.col_id] = tmap.get(col_obj.col_id, 0) + 1
     self.engine.formula_tracer = trace_call
@@ -211,17 +213,27 @@ class EngineTestCase(unittest.TestCase):
   def assertTableData(self, table_name, data=[], cols="all", rows="all", sort=None):
     """
     Verify some or all of the data in the table named `table_name`.
-    - data: an array of rows, with first row containing column names starting with "id", and
-      other rows also all starting with row_id.
+    - data: one of
+      (1) an array of rows, with first row containing column names starting with "id", and
+          other rows also all starting with row_id.
+      (2) an array of dictionaries, mapping colIds to values
+      (3) an array of namedtuples, e.g. as returned by transpose_bulk_action().
     - cols: may be "all" (default) to match all columns, or "subset" to match only those listed.
     - rows: may be "all" (default) to match all rows, or "subset" to match only those listed,
       or a function called with a Record to return whether to include it.
     - sort: optionally a key function called with a Record, for sorting observed rows.
     """
-    assert data[0][0] == 'id', "assertRecords requires 'id' as the first column"
-    col_names = data[0]
-    row_data = data[1:]
-    expected = testutil.table_data_from_rows(table_name, col_names, row_data)
+    if hasattr(data[0], '_asdict'):   # namedtuple
+      data = [r._asdict() for r in data]
+
+    if isinstance(data[0], dict):
+      expected = testutil.table_data_from_row_dicts(table_name, data)
+      col_names = ['id'] + list(expected.columns)
+    else:
+      assert data[0][0] == 'id', "assertRecords requires 'id' as the first column"
+      col_names = data[0]
+      row_data = data[1:]
+      expected = testutil.table_data_from_rows(table_name, col_names, row_data)
 
     table = self.engine.tables[table_name]
     columns = [c for c in table.all_columns.values()
@@ -236,7 +248,7 @@ class EngineTestCase(unittest.TestCase):
     if rows == "all":
       row_ids = list(table.row_ids)
     elif rows == "subset":
-      row_ids = [row[0] for row in row_data]
+      row_ids = expected.row_ids
     elif callable(rows):
       row_ids = [r.id for r in table.user_table.all if rows(r)]
     else:
diff --git a/sandbox/grist/test_lookup_find.py b/sandbox/grist/test_lookup_find.py
new file mode 100644
index 00000000..ed001bc7
--- /dev/null
+++ b/sandbox/grist/test_lookup_find.py
@@ -0,0 +1,253 @@
+import datetime
+import logging
+import unittest
+
+import six
+
+import moment
+import objtypes
+import testutil
+import test_engine
+
+log = logging.getLogger(__name__)
+
+def D(year, month, day):
+  return moment.date_to_ts(datetime.date(year, month, day))
+
+class TestLookupFind(test_engine.EngineTestCase):
+
+  def do_setup(self):
+    self.load_sample(testutil.parse_test_sample({
+      "SCHEMA": [
+        [1, "Customers", [
+          [11, "Name", "Text", False, "", "", ""],
+          [12, "MyDate", "Date", False, "", "", ""],
+        ]],
+        [2, "Purchases", [
+          [20, "manualSort", "PositionNumber", False, "", "", ""],
+          [21, "Customer", "Ref:Customers", False, "", "", ""],
+          [22, "Date", "Date", False, "", "", ""],
+          [24, "Category", "Text", False, "", "", ""],
+          [25, "Amount", "Numeric", False, "", "", ""],
+          [26, "Prev", "Ref:Purchases", True, "None", "", ""],    # To be filled
+          [27, "Cumul", "Numeric", True, "$Prev.Cumul + $Amount", "", ""],
+        ]],
+      ],
+      "DATA": {
+        "Customers": [
+          ["id", "Name",    "MyDate"],
+          [1,    "Alice",   D(2023,12,5)],
+          [2,    "Bob",     D(2023,12,10)],
+        ],
+        "Purchases": [
+          [ "id",   "manualSort", "Customer", "Date",       "Category", "Amount", ],
+          [1,       1.0,          1,          D(2023,12,1), "A",        10],
+          [2,       2.0,          2,          D(2023,12,4), "A",        17],
+          [3,       3.0,          1,          D(2023,12,3), "A",        20],
+          [4,       4.0,          1,          D(2023,12,9), "A",        40],
+          [5,       5.0,          1,          D(2023,12,2), "B",        80],
+          [6,       6.0,          1,          D(2023,12,6), "B",        160],
+          [7,       7.0,          1,          D(2023,12,7), "A",        320],
+          [8,       8.0,          1,          D(2023,12,5), "A",        640],
+        ],
+      }
+    }))
+
+  def do_test_lookup_find(self, find="find", ref_type_to_use=None):
+    self.do_setup()
+
+    if ref_type_to_use:
+      self.add_column("Customers", "PurchasesByDate", type=ref_type_to_use,
+          formula="Purchases.lookupRecords(Customer=$id, sort_by='Date')")
+      lookup = "$PurchasesByDate"
+    else:
+      lookup = "Purchases.lookupRecords(Customer=$id, sort_by='Date')"
+
+    self.add_column("Customers", "LTDate", type="Ref:Purchases",
+        formula="{}.{}.lt($MyDate)".format(lookup, find))
+    self.add_column("Customers", "LEDate", type="Ref:Purchases",
+        formula="{}.{}.le($MyDate)".format(lookup, find))
+    self.add_column("Customers", "GTDate", type="Ref:Purchases",
+        formula="{}.{}.gt($MyDate)".format(lookup, find))
+    self.add_column("Customers", "GEDate", type="Ref:Purchases",
+        formula="{}.{}.ge($MyDate)".format(lookup, find))
+    self.add_column("Customers", "EQDate", type="Ref:Purchases",
+        formula="{}.{}.eq($MyDate)".format(lookup, find))
+
+    # Here's the purchase data sorted by Customer and Date
+    # id      Customer      Date
+    # 1,       1,          D(2023,12,1)
+    # 5,       1,          D(2023,12,2)
+    # 3,       1,          D(2023,12,3)
+    # 8,       1,          D(2023,12,5)
+    # 6,       1,          D(2023,12,6)
+    # 7,       1,          D(2023,12,7)
+    # 4,       1,          D(2023,12,9)
+    # 2,       2,          D(2023,12,4)
+
+    # pylint: disable=line-too-long
+    self.assertTableData('Customers', cols="subset", data=[
+      dict(id=1, Name="Alice", MyDate=D(2023,12,5), LTDate=3, LEDate=8, GTDate=6, GEDate=8, EQDate=8),
+      dict(id=2, Name="Bob", MyDate=D(2023,12,10), LTDate=2, LEDate=2, GTDate=0, GEDate=0, EQDate=0),
+    ])
+
+    # Change Dates for Alice and Bob
+    self.update_record('Customers', 1, MyDate=D(2023,12,4))
+    self.update_record('Customers', 2, MyDate=D(2023,12,4))
+    self.assertTableData('Customers', cols="subset", data=[
+      dict(id=1, Name="Alice", MyDate=D(2023,12,4), LTDate=3, LEDate=3, GTDate=8, GEDate=8, EQDate=0),
+      dict(id=2, Name="Bob", MyDate=D(2023,12,4), LTDate=0, LEDate=2, GTDate=0, GEDate=2, EQDate=2),
+    ])
+
+    # Change a Purchase from Alice to Bob, and remove a purchase for Alice
+    self.update_record('Purchases', 5, Customer=2)
+    self.remove_record('Purchases', 3)
+    self.assertTableData('Customers', cols="subset", data=[
+      dict(id=1, Name="Alice", MyDate=D(2023,12,4), LTDate=1, LEDate=1, GTDate=8, GEDate=8, EQDate=0),
+      dict(id=2, Name="Bob", MyDate=D(2023,12,4), LTDate=5, LEDate=2, GTDate=0, GEDate=2, EQDate=2),
+    ])
+
+    # Another update to the lookup date for Bob.
+    self.update_record('Customers', 2, MyDate=D(2023,1,1))
+    self.assertTableData('Customers', cols="subset", data=[
+      dict(id=1, Name="Alice", MyDate=D(2023,12,4), LTDate=1, LEDate=1, GTDate=8, GEDate=8, EQDate=0),
+      dict(id=2, Name="Bob", MyDate=D(2023,1,1), LTDate=0, LEDate=0, GTDate=5, GEDate=5, EQDate=0),
+    ])
+
+  @unittest.skipUnless(six.PY3, "Python 3 only")
+  def test_lookup_find(self):
+    self.do_test_lookup_find()
+
+  @unittest.skipUnless(six.PY3, "Python 3 only")
+  def test_lookup_underscore_find(self):
+    # Repeat the previous test case with _find in place of find. Normally, we can use
+    # lookupRecords(...).find.*, but if a column named "find" exists, it will shadow this method,
+    # and lookupRecords(...)._find.* may be used instead (with an underscore). Check that it works.
+    self.do_test_lookup_find(find="_find")
+
+  @unittest.skipUnless(six.PY3, "Python 3 only")
+  def test_lookup_find_ref_any(self):
+    self.do_test_lookup_find(ref_type_to_use='Any')
+
+  @unittest.skipUnless(six.PY3, "Python 3 only")
+  def test_lookup_find_ref_reflist(self):
+    self.do_test_lookup_find(ref_type_to_use='RefList:Purchases')
+
+  @unittest.skipUnless(six.PY3, "Python 3 only")
+  def test_lookup_find_empty(self):
+    self.do_setup()
+    self.add_column("Customers", "P", type='RefList:Purchases',
+        formula="Purchases.lookupRecords(Customer=$id, Category='C', sort_by='Date')")
+    self.add_column("Customers", "LTDate", type="Ref:Purchases", formula="$P.find.lt($MyDate)")
+    self.add_column("Customers", "LEDate", type="Ref:Purchases", formula="$P.find.le($MyDate)")
+    self.add_column("Customers", "GTDate", type="Ref:Purchases", formula="$P.find.gt($MyDate)")
+    self.add_column("Customers", "GEDate", type="Ref:Purchases", formula="$P.find.ge($MyDate)")
+    self.add_column("Customers", "EQDate", type="Ref:Purchases", formula="$P.find.eq($MyDate)")
+
+    # pylint: disable=line-too-long
+    self.assertTableData('Customers', cols="subset", data=[
+      dict(id=1, Name="Alice", MyDate=D(2023,12,5), LTDate=0, LEDate=0, GTDate=0, GEDate=0, EQDate=0),
+      dict(id=2, Name="Bob", MyDate=D(2023,12,10), LTDate=0, LEDate=0, GTDate=0, GEDate=0, EQDate=0),
+    ])
+
+    # Check find.* results once the lookup result becomes non-empty.
+    self.update_record('Purchases', 5, Category="C")
+    self.assertTableData('Customers', cols="subset", data=[
+      dict(id=1, Name="Alice", MyDate=D(2023,12,5), LTDate=5, LEDate=5, GTDate=0, GEDate=0, EQDate=0),
+      dict(id=2, Name="Bob", MyDate=D(2023,12,10), LTDate=0, LEDate=0, GTDate=0, GEDate=0, EQDate=0),
+    ])
+
+  @unittest.skipUnless(six.PY3, "Python 3 only")
+  def test_lookup_find_unsorted(self):
+    self.do_setup()
+    self.add_column("Customers", "P", type='RefList:Purchases',
+        formula="[Purchases.lookupOne(Customer=$id)]")
+    self.add_column("Customers", "LTDate", type="Ref:Purchases", formula="$P.find.lt($MyDate)")
+    err = objtypes.RaisedException(ValueError())
+    self.assertTableData('Customers', cols="subset", data=[
+      dict(id=1, Name="Alice", MyDate=D(2023,12,5), LTDate=err),
+      dict(id=2, Name="Bob", MyDate=D(2023,12,10), LTDate=err),
+    ])
+
+
+  @unittest.skipUnless(six.PY2, "Python 2 only")
+  def test_lookup_find_py2(self):
+    self.do_setup()
+
+    self.add_column("Customers", "LTDate", type="Ref:Purchases",
+        formula="Purchases.lookupRecords(Customer=$id, sort_by='Date').find.lt($MyDate)")
+
+    err = objtypes.RaisedException(NotImplementedError())
+    self.assertTableData('Customers', data=[
+      dict(id=1, Name="Alice", MyDate=D(2023,12,5), LTDate=err),
+      dict(id=2, Name="Bob", MyDate=D(2023,12,10), LTDate=err),
+    ])
+
+
+  def test_column_named_find(self):
+    # Test that we can add a column named "find", use it, and remove it.
+    self.do_setup()
+    self.add_column("Customers", "find", type="Text")
+
+    # Check that the column is usable.
+    self.update_record("Customers", 1, find="Hello")
+    self.assertTableData('Customers', cols="all", data=[
+      dict(id=1, Name="Alice", MyDate=D(2023,12,5), find="Hello"),
+      dict(id=2, Name="Bob", MyDate=D(2023,12,10), find=""),
+    ])
+
+    # Check that we can remove the column.
+    self.remove_column("Customers", "find")
+    self.assertTableData('Customers', cols="all", data=[
+      dict(id=1, Name="Alice", MyDate=D(2023,12,5)),
+      dict(id=2, Name="Bob", MyDate=D(2023,12,10)),
+    ])
+
+
+  @unittest.skipUnless(six.PY3, "Python 3 only")
+  def test_rename_find_attrs(self):
+    """
+    Check that in formulas like Table.lookupRecords(...).find.lt(...).ColID, renames of ColID
+    update the formula.
+    """
+    # Create a simple table (People) with a couple records.
+    self.apply_user_action(["AddTable", "People", [
+      dict(id="Name", type="Text")
+    ]])
+    self.add_record("People", Name="Alice")
+    self.add_record("People", Name="Bob")
+
+    # Create a separate table that does a lookup in the People table.
+    self.apply_user_action(["AddTable", "Test", [
+      dict(id="Lookup1", type="Any", isFormula=True,
+        formula="People.lookupRecords(order_by='Name').find.ge('B').Name"),
+      dict(id="Lookup2", type="Any", isFormula=True,
+        formula="People.lookupRecords(order_by='Name')._find.eq('Alice').Name"),
+      dict(id="Lookup3", type="Any", isFormula=True,
+        formula="r = People.lookupRecords(order_by='Name').find.ge('B')\n" +
+                "PREVIOUS(r, order_by=None).Name"),
+      dict(id="Lookup4", type="Any", isFormula=True,
+        formula="r = People.lookupRecords(order_by='Name').find.eq('Alice')\n" +
+                "People.lookupRecords(order_by='Name').find.next(r).Name")
+    ]])
+    self.add_record("Test")
+
+    # Test that lookups return data as expected.
+    self.assertTableData('Test', cols="subset", data=[
+      dict(id=1, Lookup1="Bob", Lookup2="Alice", Lookup3="Alice", Lookup4="Bob")
+    ])
+
+    # Rename a column used for lookups or order_by. Lookup result shouldn't change.
+    self.apply_user_action(["RenameColumn", "People", "Name", "FullName"])
+    self.assertTableData('Test', cols="subset", data=[
+      dict(id=1, Lookup1="Bob", Lookup2="Alice", Lookup3="Alice", Lookup4="Bob")
+    ])
+
+    self.assertTableData('_grist_Tables_column', cols="subset", rows="subset", data=[
+      dict(id=6, colId="Lookup3",
+        formula="r = People.lookupRecords(order_by='FullName').find.ge('B')\n" +
+                "PREVIOUS(r, order_by=None).FullName"),
+      dict(id=7, colId="Lookup4",
+        formula="r = People.lookupRecords(order_by='FullName').find.eq('Alice')\n" +
+                "People.lookupRecords(order_by='FullName').find.next(r).FullName")
+    ])
diff --git a/sandbox/grist/test_lookup_perf.py b/sandbox/grist/test_lookup_perf.py
new file mode 100644
index 00000000..ae6f62ca
--- /dev/null
+++ b/sandbox/grist/test_lookup_perf.py
@@ -0,0 +1,115 @@
+import math
+import time
+import testutil
+import test_engine
+
+class TestLookupPerformance(test_engine.EngineTestCase):
+  def test_non_quadratic(self):
+    # This test measures performance which depends on other stuff running on the machine, which
+    # makes it inherently flaky. But if it fails legitimately, it should fail every time. So we
+    # run multiple times (3), and fail only if all of those times fail.
+    for i in range(2):
+      try:
+        return self._do_test_non_quadratic()
+      except Exception as e:
+        print("FAIL #%d" % (i + 1))
+    self._do_test_non_quadratic()
+
+  def _do_test_non_quadratic(self):
+    # If the same lookupRecords is called by many cells, it should reuse calculations, not lead to
+    # quadratic complexity. (Actually making use of the result would often still be O(N) in each
+    # cell, but here we check that just doing the lookup is O(1) amortized.)
+
+    # Table1 has columns: Date and Status, each will have just two distinct values.
+    # We add a bunch of formulas that should take constant time outside of the lookup.
+
+    # The way we test for quadratic complexity is by timing "BulkAddRecord" action that causes all
+    # rows to recalculate for a geometrically growing sequence of row counts. Then we
+    # log-transform the data and do linear regression on it. It should produce data that fits
+    # closely a line of slope 1.
+
+    self.setUp()    # Repeat setup because this test case gets called multiple times.
+    self.load_sample(testutil.parse_test_sample({
+      "SCHEMA": [
+        [1, "Table1", [
+          [1, "Date", "Date", False, "", "", ""],
+          [2, "Status", "Text", False, "", "", ""],
+          [3, "lookup_1a", "Any", True, "len(Table1.all)", "", ""],
+          [4, "lookup_2a", "Any", True, "len(Table1.lookupRecords(order_by='-Date'))", "", ""],
+          [5, "lookup_3a", "Any", True,
+            "len(Table1.lookupRecords(Status=$Status, order_by=('-Date', '-id')))", "", ""],
+          [6, "lookup_1b", "Any", True, "Table1.lookupOne().id", "", ""],
+          # Keep one legacy sort_by example (it shares implementation, so should work similarly)
+          [7, "lookup_2b", "Any", True, "Table1.lookupOne(sort_by='-Date').id", "", ""],
+          [8, "lookup_3b", "Any", True,
+            "Table1.lookupOne(Status=$Status, order_by=('-Date', '-id')).id", "", ""],
+        ]]
+      ],
+      "DATA": {}
+    }))
+
+    num_records = 0
+
+    def add_records(count):
+      assert count % 4 == 0, "Call add_records with multiples of 4 here"
+      self.add_records("Table1", ["Date", "Status"], [
+        [ "2024-01-01",  "Green" ],
+        [ "2024-01-01",  "Green" ],
+        [ "2024-02-01",  "Blue" ],
+        [ "2000-01-01",  "Blue" ],
+      ] * (count // 4))
+
+      N = num_records + count
+      self.assertTableData(
+        "Table1", cols="subset", rows="subset", data=[
+          ["id", "lookup_1a", "lookup_2a", "lookup_3a", "lookup_1b", "lookup_2b", "lookup_3b"],
+          [1,    N,           N,           N // 2,      1,           3,           N - 2],
+        ])
+      return N
+
+    # Add records in a geometric sequence
+    times = {}
+    start_time = time.time()
+    last_time = start_time
+    count_add = 20
+    while last_time < start_time + 2:       # Stop once we've spent 2 seconds
+      add_time = time.time()
+      num_records = add_records(count_add)
+      last_time = time.time()
+      times[num_records] = last_time - add_time
+      count_add *= 2
+
+    count_array = sorted(times.keys())
+    times_array = [times[r] for r in count_array]
+
+    # Perform linear regression on log-transformed data
+    log_count_array = [math.log(x) for x in count_array]
+    log_times_array = [math.log(x) for x in times_array]
+
+    # Calculate slope and intercept using the least squares method.
+    # Doing this manually so that it works in Python2 too.
+    # Otherwise, we could just use statistics.linear_regression()
+    n = len(log_count_array)
+    sum_x = sum(log_count_array)
+    sum_y = sum(log_times_array)
+    sum_xx = sum(x * x for x in log_count_array)
+    sum_xy = sum(x * y for x, y in zip(log_count_array, log_times_array))
+    slope = (n * sum_xy - sum_x * sum_y) / (n * sum_xx - sum_x * sum_x)
+    intercept = (sum_y - slope * sum_x) / n
+
+    # Calculate R-squared
+    mean_y = sum_y / n
+    ss_tot = sum((y - mean_y) ** 2 for y in log_times_array)
+    ss_res = sum((y - (slope * x + intercept)) ** 2
+        for x, y in zip(log_count_array, log_times_array))
+    r_squared = 1 - (ss_res / ss_tot)
+
+    # Check that the slope is close to 1. For log-transformed data, this means a linear
+    # relationship (a quadratic term would make the slope 2).
+    # In practice, we see slope even less 1 (because there is a non-trivial constant term), so we
+    # can assert things a bit lower than 1: 0.86 to 1.04.
+    err_msg = "Time is non-linear: slope {} R^2 {}".format(slope, r_squared)
+    self.assertAlmostEqual(slope, 0.95, delta=0.09, msg=err_msg)
+
+    # Check that R^2 is close to 1, meaning that data is very close to that line (of slope ~1).
+    self.assertAlmostEqual(r_squared, 1, delta=0.08, msg=err_msg)
diff --git a/sandbox/grist/test_lookup_sort.py b/sandbox/grist/test_lookup_sort.py
new file mode 100644
index 00000000..86289a50
--- /dev/null
+++ b/sandbox/grist/test_lookup_sort.py
@@ -0,0 +1,514 @@
+import datetime
+import logging
+import moment
+import testutil
+import test_engine
+from table import make_sort_spec
+
+log = logging.getLogger(__name__)
+
+def D(year, month, day):
+  return moment.date_to_ts(datetime.date(year, month, day))
+
+class TestLookupSort(test_engine.EngineTestCase):
+
+  def do_setup(self, order_by_arg):
+    self.load_sample(testutil.parse_test_sample({
+      "SCHEMA": [
+        [1, "Customers", [
+          [11, "Name", "Text", False, "", "", ""],
+          [12, "Lookup", "RefList:Purchases", True,
+            "Purchases.lookupRecords(Customer=$id, %s)" % order_by_arg, "", ""],
+          [13, "LookupAmount", "Any", True,
+            "Purchases.lookupRecords(Customer=$id, %s).Amount" % order_by_arg, "", ""],
+          [14, "LookupDotAmount", "Any", True, "$Lookup.Amount", "", ""],
+          [15, "LookupContains", "RefList:Purchases", True,
+            "Purchases.lookupRecords(Customer=$id, Tags=CONTAINS('foo'), %s)" % order_by_arg,
+            "", ""],
+          [16, "LookupContainsDotAmount", "Any", True, "$LookupContains.Amount", "", ""],
+        ]],
+        [2, "Purchases", [
+          [21, "Customer", "Ref:Customers", False, "", "", ""],
+          [22, "Date", "Date", False, "", "", ""],
+          [23, "Tags", "ChoiceList", False, "", "", ""],
+          [24, "Category", "Text", False, "", "", ""],
+          [25, "Amount", "Numeric", False, "", "", ""],
+        ]],
+      ],
+      "DATA": {
+        "Customers": [
+          ["id", "Name"],
+          [1,    "Alice"],
+          [2,    "Bob"],
+        ],
+        "Purchases": [
+          [ "id",   "Customer", "Date",       "Tags",   "Category", "Amount", ],
+          # Note: the tenths digit of Amount corresponds to day, for easier ordering of expected
+          # sort results.
+          [1,       1,          D(2023,12,1), ["foo"],  "A",        10.1],
+          [2,       2,          D(2023,12,4), ["foo"],  "A",        17.4],
+          [3,       1,          D(2023,12,3), ["bar"],  "A",        20.3],
+          [4,       1,          D(2023,12,9), ["foo", "bar"],  "A", 40.9],
+          [5,       1,          D(2023,12,2), ["foo", "bar"],  "B", 80.2],
+          [6,       1,          D(2023,12,6), ["bar"],  "B",        160.6],
+          [7,       1,          D(2023,12,7), ["foo"],  "A",        320.7],
+          [8,       1,          D(2023,12,5), ["bar", "foo"],  "A", 640.5],
+        ],
+      }
+    }))
+
+  def test_make_sort_spec(self):
+    """
+    Test interpretations of different kinds of order_by and sort_by params.
+    """
+    # Test the default for Table.lookupRecords.
+    self.assertEqual(make_sort_spec(('id',), None, True), ())
+    self.assertEqual(make_sort_spec(('id',), None, False), ())
+
+    # Test legacy sort_by
+    self.assertEqual(make_sort_spec(('Doh',), 'Foo', True), ('Foo',))
+    self.assertEqual(make_sort_spec(None, '-Foo', False), ('-Foo',))
+
+    # Test None, string, tuple, without manualSort.
+    self.assertEqual(make_sort_spec(None, None, False), ())
+    self.assertEqual(make_sort_spec('Bar', None, False), ('Bar',))
+    self.assertEqual(make_sort_spec(('Foo', '-Bar'), None, False), ('Foo', '-Bar'))
+
+    # Test None, string, tuple, WITH manualSort.
+    self.assertEqual(make_sort_spec(None, None, True), ('manualSort',))
+    self.assertEqual(make_sort_spec('Bar', None, True), ('Bar', 'manualSort'))
+    self.assertEqual(make_sort_spec(('Foo', '-Bar'), None, True), ('Foo', '-Bar', 'manualSort'))
+
+    # If 'manualSort' is present, should not be added twice.
+    self.assertEqual(make_sort_spec(('Foo', 'manualSort'), None, True), ('Foo', 'manualSort'))
+
+    # If 'id' is present, fields starting with it are dropped.
+    self.assertEqual(make_sort_spec(('Bar', 'id'), None, True), ('Bar',))
+    self.assertEqual(make_sort_spec(('Foo', 'id', 'manualSort', 'X'), None, True), ('Foo',))
+    self.assertEqual(make_sort_spec('id', None, True), ())
+
+  def test_lookup_sort_by_default(self):
+    """
+    Tests lookups with default sort (by row_id) using sort_by=None, and how it reacts to changes.
+    """
+    self.do_setup('sort_by=None')
+    self._do_test_lookup_sort_by_default()
+
+  def test_lookup_order_by_none(self):
+    # order_by=None means default to manualSort. But this test case should not be affected.
+    self.do_setup('order_by=None')
+    self._do_test_lookup_sort_by_default()
+
+  def _do_test_lookup_sort_by_default(self):
+    self.assertTableData("Customers", cols="subset", rows="subset", data=[
+      dict(
+        id = 1,
+        Name = "Alice",
+        Lookup = [1, 3, 4, 5, 6, 7, 8],
+        LookupAmount = [10.1, 20.3, 40.9, 80.2, 160.6, 320.7, 640.5],
+        LookupDotAmount = [10.1, 20.3, 40.9, 80.2, 160.6, 320.7, 640.5],
+        LookupContains = [1, 4, 5, 7, 8],
+        LookupContainsDotAmount = [10.1, 40.9, 80.2, 320.7, 640.5],
+      )
+    ])
+
+    # Change Customer of Purchase #2 (Bob -> Alice) and check that all got updated.
+    # (The list of purchases for Alice gets the new purchase #2.)
+    out_actions = self.update_record("Purchases", 2, Customer=1)
+    self.assertEqual(out_actions.calls["Customers"], {
+      "Lookup": 2, "LookupAmount": 2, "LookupDotAmount": 2,
+      "LookupContains": 2, "LookupContainsDotAmount": 2,
+    })
+    self.assertTableData("Customers", cols="subset", rows="subset", data=[
+      dict(
+        id = 1,
+        Name = "Alice",
+        Lookup = [1, 2, 3, 4, 5, 6, 7, 8],
+        LookupAmount = [10.1, 17.4, 20.3, 40.9, 80.2, 160.6, 320.7, 640.5],
+        LookupDotAmount = [10.1, 17.4, 20.3, 40.9, 80.2, 160.6, 320.7, 640.5],
+        LookupContains = [1, 2, 4, 5, 7, 8],
+        LookupContainsDotAmount = [10.1, 17.4, 40.9, 80.2, 320.7, 640.5],
+      )
+    ])
+
+    # Change Customer of Purchase #1 (Alice -> Bob) and check that all got updated.
+    # (The list of purchases for Alice loses the purchase #1.)
+    out_actions = self.update_record("Purchases", 1, Customer=2)
+    self.assertEqual(out_actions.calls["Customers"], {
+      "Lookup": 2, "LookupAmount": 2, "LookupDotAmount": 2,
+      "LookupContains": 2, "LookupContainsDotAmount": 2,
+    })
+    self.assertTableData("Customers", cols="subset", rows="subset", data=[
+      dict(
+        id = 1,
+        Name = "Alice",
+        Lookup = [2, 3, 4, 5, 6, 7, 8],
+        LookupAmount = [17.4, 20.3, 40.9, 80.2, 160.6, 320.7, 640.5],
+        LookupDotAmount = [17.4, 20.3, 40.9, 80.2, 160.6, 320.7, 640.5],
+        LookupContains = [2, 4, 5, 7, 8],
+        LookupContainsDotAmount = [17.4, 40.9, 80.2, 320.7, 640.5],
+      )
+    ])
+
+    # Change Date of Purchase #3 to much earlier, and check that all got updated.
+    out_actions = self.update_record("Purchases", 3, Date=D(2023,8,1))
+    # Nothing to recompute in this case, since it doesn't depend on Date.
+    self.assertEqual(out_actions.calls.get("Customers"), None)
+
+    # Change Amount of Purchase #3 to much larger, and check that just amounts got updated.
+    out_actions = self.update_record("Purchases", 3, Amount=999999)
+    self.assertEqual(out_actions.calls["Customers"], {
+      # Lookups that don't depend on Amount aren't recalculated
+      "LookupAmount": 1, "LookupDotAmount": 1,
+    })
+    self.assertTableData("Customers", cols="subset", rows="subset", data=[
+      dict(
+        id = 1,
+        Name = "Alice",
+        Lookup = [2, 3, 4, 5, 6, 7, 8],
+        LookupAmount = [17.4, 999999, 40.9, 80.2, 160.6, 320.7, 640.5],
+        LookupDotAmount = [17.4, 999999, 40.9, 80.2, 160.6, 320.7, 640.5],
+        LookupContains = [2, 4, 5, 7, 8],
+        LookupContainsDotAmount = [17.4, 40.9, 80.2, 320.7, 640.5],
+      )
+    ])
+
+  def test_lookup_sort_by_date(self):
+    """
+    Tests lookups with sort by "-Date", and how it reacts to changes.
+    """
+    self.do_setup('sort_by="-Date"')
+    self._do_test_lookup_sort_by_date()
+
+  def test_lookup_order_by_date(self):
+    # With order_by, we'll fall back to manualSort, but this shouldn't matter here.
+    self.do_setup('order_by="-Date"')
+    self._do_test_lookup_sort_by_date()
+
+  def _do_test_lookup_sort_by_date(self):
+    self.assertTableData("Customers", cols="subset", rows="subset", data=[
+      dict(
+        id = 1,
+        Name = "Alice",
+        Lookup = [4, 7, 6, 8, 3, 5, 1],
+        LookupAmount = [40.9, 320.7, 160.6, 640.5, 20.3, 80.2, 10.1],
+        LookupDotAmount = [40.9, 320.7, 160.6, 640.5, 20.3, 80.2, 10.1],
+        LookupContains = [4, 7, 8, 5, 1],
+        LookupContainsDotAmount = [40.9, 320.7, 640.5, 80.2, 10.1],
+      )
+    ])
+
+    # Change Customer of Purchase #2 (Bob -> Alice) and check that all got updated.
+    # (The list of purchases for Alice gets the new purchase #2.)
+    out_actions = self.update_record("Purchases", 2, Customer=1)
+    self.assertEqual(out_actions.calls["Customers"], {
+      "Lookup": 2, "LookupAmount": 2, "LookupDotAmount": 2,
+      "LookupContains": 2, "LookupContainsDotAmount": 2,
+    })
+    self.assertTableData("Customers", cols="subset", rows="subset", data=[
+      dict(
+        id = 1,
+        Name = "Alice",
+        Lookup = [4, 7, 6, 8, 2, 3, 5, 1],
+        LookupAmount = [40.9, 320.7, 160.6, 640.5, 17.4, 20.3, 80.2, 10.1],
+        LookupDotAmount = [40.9, 320.7, 160.6, 640.5, 17.4, 20.3, 80.2, 10.1],
+        LookupContains = [4, 7, 8, 2, 5, 1],
+        LookupContainsDotAmount = [40.9, 320.7, 640.5, 17.4, 80.2, 10.1],
+      )
+    ])
+
+    # Change Customer of Purchase #1 (Alice -> Bob) and check that all got updated.
+    # (The list of purchases for Alice loses the purchase #1.)
+    out_actions = self.update_record("Purchases", 1, Customer=2)
+    self.assertEqual(out_actions.calls["Customers"], {
+      "Lookup": 2, "LookupAmount": 2, "LookupDotAmount": 2,
+      "LookupContains": 2, "LookupContainsDotAmount": 2,
+    })
+    self.assertTableData("Customers", cols="subset", rows="subset", data=[
+      dict(
+        id = 1,
+        Name = "Alice",
+        Lookup = [4, 7, 6, 8, 2, 3, 5],
+        LookupAmount = [40.9, 320.7, 160.6, 640.5, 17.4, 20.3, 80.2],
+        LookupDotAmount = [40.9, 320.7, 160.6, 640.5, 17.4, 20.3, 80.2],
+        LookupContains = [4, 7, 8, 2, 5],
+        LookupContainsDotAmount = [40.9, 320.7, 640.5, 17.4, 80.2],
+      )
+    ])
+
+    # Change Date of Purchase #3 to much earlier, and check that all got updated.
+    out_actions = self.update_record("Purchases", 3, Date=D(2023,8,1))
+    self.assertEqual(out_actions.calls.get("Customers"), {
+      # Only the affected lookups are affected
+      "Lookup": 1, "LookupAmount": 1, "LookupDotAmount": 1
+    })
+    self.assertTableData("Customers", cols="subset", rows="subset", data=[
+      dict(
+        id = 1,
+        Name = "Alice",
+        Lookup = [4, 7, 6, 8, 2, 5, 3],
+        LookupAmount = [40.9, 320.7, 160.6, 640.5, 17.4, 80.2, 20.3],
+        LookupDotAmount = [40.9, 320.7, 160.6, 640.5, 17.4, 80.2, 20.3],
+        LookupContains = [4, 7, 8, 2, 5],
+        LookupContainsDotAmount = [40.9, 320.7, 640.5, 17.4, 80.2],
+      )
+    ])
+
+    # Change Amount of Purchase #3 to much larger, and check that just amounts got updated.
+    out_actions = self.update_record("Purchases", 3, Amount=999999)
+    self.assertEqual(out_actions.calls["Customers"], {
+      # Lookups that don't depend on Amount aren't recalculated
+      "LookupAmount": 1, "LookupDotAmount": 1,
+    })
+    self.assertTableData("Customers", cols="subset", rows="subset", data=[
+      dict(
+        id = 1,
+        Name = "Alice",
+        Lookup = [4, 7, 6, 8, 2, 5, 3],
+        LookupAmount = [40.9, 320.7, 160.6, 640.5, 17.4, 80.2, 999999],
+        LookupDotAmount = [40.9, 320.7, 160.6, 640.5, 17.4, 80.2, 999999],
+        LookupContains = [4, 7, 8, 2, 5],
+        LookupContainsDotAmount = [40.9, 320.7, 640.5, 17.4, 80.2],
+      )
+    ])
+
+
+  def test_lookup_order_by_tuple(self):
+    """
+    Tests lookups with order by ("Category", "-Date"), and how it reacts to changes.
+    """
+    self.do_setup('order_by=("Category", "-Date")')
+    self.assertTableData("Customers", cols="subset", rows="subset", data=[
+      dict(
+        id = 1,
+        Name = "Alice",
+        Lookup = [4, 7, 8, 3, 1, 6, 5],
+        LookupAmount = [40.9, 320.7, 640.5, 20.3, 10.1, 160.6, 80.2],
+        LookupDotAmount = [40.9, 320.7, 640.5, 20.3, 10.1, 160.6, 80.2],
+        LookupContains = [4, 7, 8, 1, 5],
+        LookupContainsDotAmount = [40.9, 320.7, 640.5, 10.1, 80.2],
+      )
+    ])
+
+    # Change Customer of Purchase #2 (Bob -> Alice) and check that all got updated.
+    # (The list of purchases for Alice gets the new purchase #2.)
+    out_actions = self.update_record("Purchases", 2, Customer=1)
+    self.assertEqual(out_actions.calls["Customers"], {
+      "Lookup": 2, "LookupAmount": 2, "LookupDotAmount": 2,
+      "LookupContains": 2, "LookupContainsDotAmount": 2,
+    })
+    self.assertTableData("Customers", cols="subset", rows="subset", data=[
+      dict(
+        id = 1,
+        Name = "Alice",
+        Lookup = [4, 7, 8, 2, 3, 1, 6, 5],
+        LookupAmount = [40.9, 320.7, 640.5, 17.4, 20.3, 10.1, 160.6, 80.2],
+        LookupDotAmount = [40.9, 320.7, 640.5, 17.4, 20.3, 10.1, 160.6, 80.2],
+        LookupContains = [4, 7, 8, 2, 1, 5],
+        LookupContainsDotAmount = [40.9, 320.7, 640.5, 17.4, 10.1, 80.2],
+      )
+    ])
+
+    # Change Customer of Purchase #1 (Alice -> Bob) and check that all got updated.
+    # (The list of purchases for Alice loses the purchase #1.)
+    out_actions = self.update_record("Purchases", 1, Customer=2)
+    self.assertEqual(out_actions.calls["Customers"], {
+      "Lookup": 2, "LookupAmount": 2, "LookupDotAmount": 2,
+      "LookupContains": 2, "LookupContainsDotAmount": 2,
+    })
+    self.assertTableData("Customers", cols="subset", rows="subset", data=[
+      dict(
+        id = 1,
+        Name = "Alice",
+        Lookup = [4, 7, 8, 2, 3, 6, 5],
+        LookupAmount = [40.9, 320.7, 640.5, 17.4, 20.3, 160.6, 80.2],
+        LookupDotAmount = [40.9, 320.7, 640.5, 17.4, 20.3, 160.6, 80.2],
+        LookupContains = [4, 7, 8, 2, 5],
+        LookupContainsDotAmount = [40.9, 320.7, 640.5, 17.4, 80.2],
+      )
+    ])
+
+    # Change Date of Purchase #3 to much earlier, and check that all got updated.
+    out_actions = self.update_record("Purchases", 3, Date=D(2023,8,1))
+    self.assertEqual(out_actions.calls.get("Customers"), {
+      # Only the affected lookups are affected
+      "Lookup": 1, "LookupAmount": 1, "LookupDotAmount": 1
+    })
+    # Actually this happens to be unchanged, because within the category, the new date is still in
+    # the same position.
+    self.assertTableData("Customers", cols="subset", rows="subset", data=[
+      dict(
+        id = 1,
+        Name = "Alice",
+        Lookup = [4, 7, 8, 2, 3, 6, 5],
+        LookupAmount = [40.9, 320.7, 640.5, 17.4, 20.3, 160.6, 80.2],
+        LookupDotAmount = [40.9, 320.7, 640.5, 17.4, 20.3, 160.6, 80.2],
+        LookupContains = [4, 7, 8, 2, 5],
+        LookupContainsDotAmount = [40.9, 320.7, 640.5, 17.4, 80.2],
+      )
+    ])
+
+    # Change Category of Purchase #3 to "B", and check that it got moved.
+    out_actions = self.update_record("Purchases", 3, Category="B")
+    self.assertEqual(out_actions.calls.get("Customers"), {
+      # Only the affected lookups are affected
+      "Lookup": 1, "LookupAmount": 1, "LookupDotAmount": 1
+    })
+    self.assertTableData("Customers", cols="subset", rows="subset", data=[
+      dict(
+        id = 1,
+        Name = "Alice",
+        Lookup = [4, 7, 8, 2, 6, 5, 3],
+        LookupAmount = [40.9, 320.7, 640.5, 17.4, 160.6, 80.2, 20.3],
+        LookupDotAmount = [40.9, 320.7, 640.5, 17.4, 160.6, 80.2, 20.3],
+        LookupContains = [4, 7, 8, 2, 5],
+        LookupContainsDotAmount = [40.9, 320.7, 640.5, 17.4, 80.2],
+      )
+    ])
+
+    # Change Amount of Purchase #3 to much larger, and check that just amounts got updated.
+    out_actions = self.update_record("Purchases", 3, Amount=999999)
+    self.assertEqual(out_actions.calls["Customers"], {
+      # Lookups that don't depend on Amount aren't recalculated
+      "LookupAmount": 1, "LookupDotAmount": 1,
+    })
+    self.assertTableData("Customers", cols="subset", rows="subset", data=[
+      dict(
+        id = 1,
+        Name = "Alice",
+        Lookup = [4, 7, 8, 2, 6, 5, 3],
+        LookupAmount = [40.9, 320.7, 640.5, 17.4, 160.6, 80.2, 999999],
+        LookupDotAmount = [40.9, 320.7, 640.5, 17.4, 160.6, 80.2, 999999],
+        LookupContains = [4, 7, 8, 2, 5],
+        LookupContainsDotAmount = [40.9, 320.7, 640.5, 17.4, 80.2],
+      )
+    ])
+
+  def test_lookup_one(self):
+    self.do_setup('order_by=None')
+
+    # Check that the first value returned by default is the one with the lowest row ID.
+    self.add_column('Customers', 'One', type="Ref:Purchases",
+        formula="Purchases.lookupOne(Customer=$id)")
+    self.assertTableData("Customers", cols="subset", rows="subset", data=[
+      dict(id = 1, Name = "Alice", One = 1),
+      dict(id = 2, Name = "Bob", One = 2),
+    ])
+
+    # Check that the first value returned with "-Date" is the one with the highest Date.
+    self.modify_column('Customers', 'One',
+        formula="Purchases.lookupOne(Customer=$id, order_by=('-Date',))")
+    self.assertTableData("Customers", cols="subset", rows="subset", data=[
+      dict(id = 1, Name = "Alice", One = 4),
+      dict(id = 2, Name = "Bob", One = 2),
+    ])
+
+    # Check that the first value returned with "-id" is the one with the highest row ID.
+    self.modify_column('Customers', 'One',
+        formula="Purchases.lookupOne(Customer=$id, order_by='-id')")
+    self.assertTableData("Customers", cols="subset", rows="subset", data=[
+      dict(id = 1, Name = "Alice", One = 8),
+      dict(id = 2, Name = "Bob", One = 2),
+    ])
+
+
+  def test_renaming_order_by_str(self):
+    # Given some lookups with order_by, rename a column used in order_by. Check order_by got
+    # adjusted, and the results are correct. Try for order_by as string.
+    self.do_setup("order_by='-Date'")
+    self.apply_user_action(['RenameColumn', 'Purchases', 'Category', 'cat'])
+    self.apply_user_action(['RenameColumn', 'Purchases', 'Date', 'Fecha'])
+
+    self.assertTableData('_grist_Tables_column', cols="subset", rows="subset", data=[
+      dict(id=12, colId="Lookup",
+        formula="Purchases.lookupRecords(Customer=$id, order_by='-Fecha')"),
+      dict(id=13, colId="LookupAmount",
+        formula="Purchases.lookupRecords(Customer=$id, order_by='-Fecha').Amount"),
+    ])
+
+    self.assertTableData("Customers", cols="subset", rows="subset", data=[
+      dict(
+        id = 1,
+        Name = "Alice",
+        Lookup = [4, 7, 6, 8, 3, 5, 1],
+        LookupAmount = [40.9, 320.7, 160.6, 640.5, 20.3, 80.2, 10.1],
+        LookupDotAmount = [40.9, 320.7, 160.6, 640.5, 20.3, 80.2, 10.1],
+        LookupContains = [4, 7, 8, 5, 1],
+        LookupContainsDotAmount = [40.9, 320.7, 640.5, 80.2, 10.1],
+      )
+    ])
+
+    # Change the (renamed) Date of Purchase #1 to much later, and check that all got updated.
+    self.update_record("Purchases", 1, Fecha=D(2024,12,31))
+    self.assertTableData("Customers", cols="subset", rows="subset", data=[
+      dict(
+        id = 1,
+        Name = "Alice",
+        Lookup = [1, 4, 7, 6, 8, 3, 5],
+        LookupAmount = [10.1, 40.9, 320.7, 160.6, 640.5, 20.3, 80.2],
+        LookupDotAmount = [10.1, 40.9, 320.7, 160.6, 640.5, 20.3, 80.2],
+        LookupContains = [1, 4, 7, 8, 5],
+        LookupContainsDotAmount = [10.1, 40.9, 320.7, 640.5, 80.2],
+      )
+    ])
+
+
+  def test_renaming_order_by_tuple(self):
+    # Given some lookups with order_by, rename a column used in order_by. Check order_by got
+    # adjusted, and the results are correct. Try for order_by as tuple.
+    self.do_setup("order_by=('Category', '-Date')")
+
+    out_actions = self.apply_user_action(['RenameColumn', 'Purchases', 'Category', 'cat'])
+
+    # Check returned actions to ensure we don't produce actions for any stale lookup helper columns
+    # (this is a way to check that we don't forget to clean up stale lookup helper columns).
+    # pylint: disable=line-too-long
+    self.assertPartialOutActions(out_actions, {
+      "stored": [
+        ["RenameColumn", "Purchases", "Category", "cat"],
+        ["ModifyColumn", "Customers", "Lookup", {"formula": "Purchases.lookupRecords(Customer=$id, order_by=('cat', '-Date'))"}],
+        ["ModifyColumn", "Customers", "LookupAmount", {"formula": "Purchases.lookupRecords(Customer=$id, order_by=('cat', '-Date')).Amount"}],
+        ["ModifyColumn", "Customers", "LookupContains", {"formula": "Purchases.lookupRecords(Customer=$id, Tags=CONTAINS('foo'), order_by=('cat', '-Date'))"}],
+        ["BulkUpdateRecord", "_grist_Tables_column", [24, 12, 13, 15], {"colId": ["cat", "Lookup", "LookupAmount", "LookupContains"], "formula": [
+          "",
+          "Purchases.lookupRecords(Customer=$id, order_by=('cat', '-Date'))",
+          "Purchases.lookupRecords(Customer=$id, order_by=('cat', '-Date')).Amount",
+          "Purchases.lookupRecords(Customer=$id, Tags=CONTAINS('foo'), order_by=('cat', '-Date'))",
+          ]}],
+        ]
+    })
+
+    self.apply_user_action(['RenameColumn', 'Purchases', 'Date', 'Fecha'])
+
+    self.assertTableData('_grist_Tables_column', cols="subset", rows="subset", data=[
+      dict(id=12, colId="Lookup",
+        formula="Purchases.lookupRecords(Customer=$id, order_by=('cat', '-Fecha'))"),
+      dict(id=13, colId="LookupAmount",
+        formula="Purchases.lookupRecords(Customer=$id, order_by=('cat', '-Fecha')).Amount"),
+    ])
+
+    self.assertTableData("Customers", cols="subset", rows="subset", data=[
+      dict(
+        id = 1,
+        Name = "Alice",
+        Lookup = [4, 7, 8, 3, 1, 6, 5],
+        LookupAmount = [40.9, 320.7, 640.5, 20.3, 10.1, 160.6, 80.2],
+        LookupDotAmount = [40.9, 320.7, 640.5, 20.3, 10.1, 160.6, 80.2],
+        LookupContains = [4, 7, 8, 1, 5],
+        LookupContainsDotAmount = [40.9, 320.7, 640.5, 10.1, 80.2],
+      )
+    ])
+
+    # Change the (renamed) Date of Purchase #3 to much earlier, and check that all got updated.
+    self.update_record("Purchases", 3, Fecha=D(2023,8,1))
+    self.assertTableData("Customers", cols="subset", rows="subset", data=[
+      dict(
+        id = 1,
+        Name = "Alice",
+        Lookup = [4, 7, 8, 1, 3, 6, 5],
+        LookupAmount = [40.9, 320.7, 640.5, 10.1, 20.3, 160.6, 80.2],
+        LookupDotAmount = [40.9, 320.7, 640.5, 10.1, 20.3, 160.6, 80.2],
+        LookupContains = [4, 7, 8, 1, 5],
+        LookupContainsDotAmount = [40.9, 320.7, 640.5, 10.1, 80.2],
+      )
+    ])
diff --git a/sandbox/grist/test_lookups.py b/sandbox/grist/test_lookups.py
index 9b69c72a..65f2142c 100644
--- a/sandbox/grist/test_lookups.py
+++ b/sandbox/grist/test_lookups.py
@@ -773,6 +773,13 @@ return ",".join(str(r.id) for r in Students.lookupRecords(firstName=fn, lastName
           [9, "lookup_max_num",
            "Any", True,
            "Table1.lookupOne(is_num=True, sort_by='-num').num", "", ""],
+
+          [10, "lookup_2a", "Any", True,
+           "Table1.lookupRecords(order_by=('is_num', 'num')).num", "", ""],
+          [10, "lookup_2b", "Any", True,
+           "Table1.lookupRecords(order_by=('is_num', '-num')).num", "", ""],
+          [10, "lookup_2c", "Any", True,
+           "Table1.lookupRecords(order_by=('-is_num', 'num')).num", "", ""],
         ]]
       ],
       "DATA": {
@@ -795,13 +802,42 @@ return ",".join(str(r.id) for r in Students.lookupRecords(firstName=fn, lastName
          "lookup_reverse",
          "lookup_first",
          "lookup_min", "lookup_min_num",
-         "lookup_max", "lookup_max_num"],
+         "lookup_max", "lookup_max_num",
+         "lookup_2a", "lookup_2b", "lookup_2c"],
         [1,
          [None, 0, 1, 2, 3, 'foo'],
          ['foo', 3, 2, 1, 0, None],
          2,  # lookup_first: first record (by id)
          None, 0,  # lookup_min[_num]
-         'foo', 3],  # lookup_max[_num]
+         'foo', 3,  # lookup_max[_num]
+        [None, 'foo', 0, 1, 2, 3],   # lookup_2a ('is_num', 'num')
+        ['foo', None, 3, 2, 1, 0],   # lookup_2b ('is_num', '-num')
+        [0, 1, 2, 3, None, 'foo'],   # lookup_2c ('-is_num', 'num')
+        ]
+      ])
+
+    # Ensure that changes in values used for sorting result in updates,
+    # and produce correctly sorted updates.
+    self.update_record("Table1", 2, num=100)
+    self.assertTableData(
+      "Table1", cols="subset", rows="subset", data=[
+        ["id",
+         "lookup",
+         "lookup_reverse",
+         "lookup_first",
+         "lookup_min", "lookup_min_num",
+         "lookup_max", "lookup_max_num",
+         "lookup_2a", "lookup_2b", "lookup_2c"],
+        [1,
+         [None, 0, 2, 3, 100, 'foo'],
+         ['foo', 100, 3, 2, 0, None],
+         2,  # lookup_first: first record (by id)
+         None, 0,  # lookup_min[_num]
+         'foo', 100,  # lookup_max[_num]
+        [None, 'foo', 0, 2, 3, 100],   # lookup_2a ('is_num', 'num')
+        ['foo', None, 100, 3, 2, 0],   # lookup_2b ('is_num', '-num')
+        [0, 2, 3, 100, None, 'foo'],   # lookup_2c ('-is_num', 'num')
+        ]
       ])
 
   def test_conversion(self):
diff --git a/sandbox/grist/test_prevnext.py b/sandbox/grist/test_prevnext.py
new file mode 100644
index 00000000..c966b893
--- /dev/null
+++ b/sandbox/grist/test_prevnext.py
@@ -0,0 +1,389 @@
+import datetime
+import functools
+import itertools
+import logging
+import unittest
+import six
+
+import actions
+from column import SafeSortKey
+import moment
+import objtypes
+import testutil
+import test_engine
+
+log = logging.getLogger(__name__)
+
+def D(year, month, day):
+  return moment.date_to_ts(datetime.date(year, month, day))
+
+
+class TestPrevNext(test_engine.EngineTestCase):
+
+  def do_setup(self):
+    self.load_sample(testutil.parse_test_sample({
+      "SCHEMA": [
+        [1, "Customers", [
+          [11, "Name", "Text", False, "", "", ""],
+        ]],
+        [2, "Purchases", [
+          [20, "manualSort", "PositionNumber", False, "", "", ""],
+          [21, "Customer", "Ref:Customers", False, "", "", ""],
+          [22, "Date", "Date", False, "", "", ""],
+          [24, "Category", "Text", False, "", "", ""],
+          [25, "Amount", "Numeric", False, "", "", ""],
+          [26, "Prev", "Ref:Purchases", True, "None", "", ""],    # To be filled
+          [27, "Cumul", "Numeric", True, "$Prev.Cumul + $Amount", "", ""],
+        ]],
+      ],
+      "DATA": {
+        "Customers": [
+          ["id", "Name"],
+          [1,    "Alice"],
+          [2,    "Bob"],
+        ],
+        "Purchases": [
+          [ "id",   "manualSort", "Customer", "Date",       "Category", "Amount", ],
+          [1,       1.0,          1,          D(2023,12,1), "A",        10],
+          [2,       2.0,          2,          D(2023,12,4), "A",        17],
+          [3,       3.0,          1,          D(2023,12,3), "A",        20],
+          [4,       4.0,          1,          D(2023,12,9), "A",        40],
+          [5,       5.0,          1,          D(2023,12,2), "B",        80],
+          [6,       6.0,          1,          D(2023,12,6), "B",        160],
+          [7,       7.0,          1,          D(2023,12,7), "A",        320],
+          [8,       8.0,          1,          D(2023,12,5), "A",        640],
+        ],
+      }
+    }))
+
+  def calc_expected(self, group_key=None, sort_key=None, sort_reverse=False):
+    # Returns expected {id, Prev, Cumul} values from Purchases table calculated according to the
+    # given grouping and sorting parameters.
+    group_key = group_key or (lambda r: 0)
+    data = list(actions.transpose_bulk_action(self.engine.fetch_table('Purchases')))
+    expected = []
+    sorted_data = sorted(data, key=sort_key, reverse=sort_reverse)
+    sorted_data = sorted(sorted_data, key=group_key)
+    for key, group in itertools.groupby(sorted_data, key=group_key):
+      prev = 0
+      cumul = 0.0
+      for r in group:
+        cumul = round(cumul + r.Amount, 2)
+        expected.append({"id": r.id, "Prev": prev, "Cumul": cumul})
+        prev = r.id
+    expected.sort(key=lambda r: r["id"])
+    return expected
+
+  def do_test(self, formula, group_key=None, sort_key=None, sort_reverse=False):
+    calc_expected = lambda: self.calc_expected(
+        group_key=group_key, sort_key=sort_key, sort_reverse=sort_reverse)
+
+    def assertPrevValid():
+      # Check that Prev column is legitimate values, e.g. not errors.
+      prev = self.engine.fetch_table('Purchases').columns["Prev"]
+      self.assertTrue(is_all_ints(prev), "Prev column contains invalid values: %s" %
+          [objtypes.encode_object(x) for x in prev])
+
+    # This verification works as follows:
+    # (1) Set "Prev" column to the specified formula.
+    # (2) Calculate expected values for "Prev" and "Cumul" manually, and compare to reality.
+    # (3) Try a few actions that affect the data, and calculate again.
+    self.do_setup()
+    self.modify_column('Purchases', 'Prev', formula=formula)
+
+    # Check the initial data.
+    assertPrevValid()
+    self.assertTableData('Purchases', cols="subset", data=calc_expected())
+
+    # Check the result after removing a record.
+    self.remove_record('Purchases', 6)
+    self.assertTableData('Purchases', cols="subset", data=calc_expected())
+
+    # Check the result after updating a record
+    self.update_record('Purchases', 5, Amount=1080)   # original value +1000
+    self.assertTableData('Purchases', cols="subset", data=calc_expected())
+
+    first_date = D(2023, 8, 1)
+
+    # Update a few other records
+    self.update_record("Purchases", 2, Customer=1)
+    self.update_record("Purchases", 1, Customer=2)
+    self.update_record("Purchases", 3, Date=first_date)   # becomes earliest in date order
+    assertPrevValid()
+    self.assertTableData('Purchases', cols="subset", data=calc_expected())
+
+    # Check the result after re-adding a record
+    # Note that Date here matches new date of record #3. This tests sort fallback to rowId.
+    # Amount is the original amount +1.
+    self.add_record('Purchases', 6, manualSort=6.0, Date=first_date, Amount=161)
+    self.assertTableData('Purchases', cols="subset", data=calc_expected())
+
+    # Update the manualSort value to test how it affects sort results.
+    self.update_record('Purchases', 6, manualSort=0.5)
+    self.assertTableData('Purchases', cols="subset", data=calc_expected())
+    assertPrevValid()
+
+  def do_test_prevnext(self, formula, group_key=None, sort_key=None, sort_reverse=False):
+    # Run do_test() AND also repeat it after replacing PREVIOUS with NEXT in formula, and
+    # reversing the expected results.
+
+    # Note that this is a bit fragile: it relies on do_test() being limited to only the kinds of
+    # changes that would be reset by another call to self.load_sample().
+
+    with self.subTest(formula=formula):   # pylint: disable=no-member
+      self.do_test(formula, group_key=group_key, sort_key=sort_key, sort_reverse=sort_reverse)
+
+    nformula = formula.replace('PREVIOUS', 'NEXT')
+    with self.subTest(formula=nformula):  # pylint: disable=no-member
+      self.do_test(nformula, group_key=group_key, sort_key=sort_key, sort_reverse=not sort_reverse)
+
+  @unittest.skipUnless(six.PY3, "Python 3 only")
+  def test_prevnext_none(self):
+    self.do_test_prevnext("PREVIOUS(rec, order_by=None)", group_key=None,
+        sort_key=lambda r: r.manualSort)
+
+    # Check that order_by arg is required (get TypeError without it).
+    with self.assertRaisesRegex(AssertionError, r'Prev column contains invalid values:.*TypeError'):
+      self.do_test("PREVIOUS(rec)", sort_key=lambda r: -r.id)
+
+    # These assertions are just to ensure that do_test() tests do exercise the feature being
+    # tested, i.e. fail when comparisons are NOT correct.
+    with self.assertRaisesRegex(AssertionError, r'Observed data not as expected'):
+      self.do_test("PREVIOUS(rec, order_by=None)", sort_key=lambda r: -r.id)
+    with self.assertRaisesRegex(AssertionError, r'Observed data not as expected'):
+      self.do_test("PREVIOUS(rec, order_by=None)", group_key=(lambda r: r.Customer),
+          sort_key=(lambda r: r.id))
+
+    # Make sure the test case above exercises the disambiguation by 'manualSort' (i.e. fails if
+    # 'manualSort' isn't used to disambiguate).
+    with self.assertRaisesRegex(AssertionError, r'Observed data not as expected'):
+      self.do_test("PREVIOUS(rec, order_by=None)", sort_key=lambda r: r.id)
+
+  @unittest.skipUnless(six.PY3, "Python 3 only")
+  def test_prevnext_date(self):
+    self.do_test_prevnext("PREVIOUS(rec, order_by='Date')",
+        group_key=None, sort_key=lambda r: (SafeSortKey(r.Date), r.manualSort))
+
+    # Make sure the test case above exercises the disambiguation by 'manualSort' (i.e. fails if it
+    # isn't used to disambiguate).
+    with self.assertRaisesRegex(AssertionError, r'Observed data not as expected'):
+      self.do_test("PREVIOUS(rec, order_by='Date')",
+          group_key=None, sort_key=lambda r: (SafeSortKey(r.Date), r.id))
+
+  @unittest.skipUnless(six.PY3, "Python 3 only")
+  def test_prevnext_date_manualsort(self):
+    # Same as the previous test case (with just 'Date'), but specifies 'manualSort' explicitly.
+    self.do_test_prevnext("PREVIOUS(rec, order_by=('Date', 'manualSort'))",
+        group_key=None, sort_key=lambda r: (SafeSortKey(r.Date), r.manualSort))
+
+  @unittest.skipUnless(six.PY3, "Python 3 only")
+  def test_prevnext_rdate(self):
+    self.do_test_prevnext("PREVIOUS(rec, order_by='-Date')",
+        group_key=None, sort_key=lambda r: (SafeSortKey(r.Date), -r.manualSort), sort_reverse=True)
+
+  @unittest.skipUnless(six.PY3, "Python 3 only")
+  def test_prevnext_rdate_id(self):
+    self.do_test_prevnext("PREVIOUS(rec, order_by=('-Date', 'id'))",
+        group_key=None, sort_key=lambda r: (SafeSortKey(r.Date), -r.id), sort_reverse=True)
+
+  @unittest.skipUnless(six.PY3, "Python 3 only")
+  def test_prevnext_customer_rdate(self):
+    self.do_test_prevnext("PREVIOUS(rec, group_by=('Customer',), order_by='-Date')",
+        group_key=(lambda r: r.Customer), sort_key=lambda r: (SafeSortKey(r.Date), -r.id),
+        sort_reverse=True)
+
+  @unittest.skipUnless(six.PY3, "Python 3 only")
+  def test_prevnext_category_date(self):
+    self.do_test_prevnext("PREVIOUS(rec, group_by=('Category',), order_by='Date')",
+        group_key=(lambda r: r.Category), sort_key=lambda r: SafeSortKey(r.Date))
+
+  @unittest.skipUnless(six.PY3, "Python 3 only")
+  def test_prevnext_category_date2(self):
+    self.do_test_prevnext("PREVIOUS(rec, group_by='Category', order_by='Date')",
+        group_key=(lambda r: r.Category), sort_key=lambda r: SafeSortKey(r.Date))
+
+  @unittest.skipUnless(six.PY3, "Python 3 only")
+  def test_prevnext_n_cat_date(self):
+    self.do_test_prevnext("PREVIOUS(rec, order_by=('Category', 'Date'))",
+        sort_key=lambda r: (SafeSortKey(r.Category), SafeSortKey(r.Date)))
+
+  @unittest.skipUnless(six.PY2, "Python 2 only")
+  def test_prevnext_py2(self):
+    # On Python2, we expect NEXT/PREVIOUS to raise a NotImplementedError. It's not hard to make
+    # it work, but the stricter argument syntax supported by Python3 is helpful, and we'd like
+    # to drop Python2 support anyway.
+    self.do_setup()
+    self.modify_column('Purchases', 'Prev', formula='PREVIOUS(rec, order_by=None)')
+    self.add_column('Purchases', 'Next', formula="NEXT(rec, group_by='Category', order_by='Date')")
+    self.add_column('Purchases', 'Rank', formula="RANK(rec, order_by='Date', order='desc')")
+
+    # Check that all values are the expected exception.
+    err = objtypes.RaisedException(NotImplementedError())
+    self.assertTableData('Purchases', cols="subset", data=[
+      dict(id=r, Prev=err, Next=err, Rank=err, Cumul=err) for r in range(1, 9)
+    ])
+
+
+  def do_test_renames(self, formula, renamed_formula, calc_expected_pre, calc_expected_post):
+    self.do_setup()
+    self.modify_column('Purchases', 'Prev', formula=formula)
+
+    # Check the initial data.
+    self.assertTableData('Purchases', cols="subset", data=calc_expected_pre())
+
+    # Do the renames
+    self.apply_user_action(['RenameColumn', 'Purchases', 'Category', 'cat'])
+    self.apply_user_action(['RenameColumn', 'Purchases', 'Date', 'Fecha'])
+    self.apply_user_action(['RenameColumn', 'Purchases', 'Customer', 'person'])
+
+    # Check that rename worked.
+    self.assertTableData('_grist_Tables_column', cols="subset", rows="subset", data=[
+      dict(id=26, colId="Prev", formula=renamed_formula)
+    ])
+
+    # Check that data is as expected, and reacts to changes.
+    self.assertTableData('Purchases', cols="subset", data=calc_expected_post())
+
+    self.update_record("Purchases", 1, cat="B")
+    self.assertTableData('Purchases', cols="subset", data=calc_expected_post())
+
+    self.update_record("Purchases", 3, Fecha=D(2023,8,1))
+    self.assertTableData('Purchases', cols="subset", data=calc_expected_post())
+
+  @unittest.skipUnless(six.PY3, "Python 3 only")
+  def test_renaming_prev_str(self):
+    self.do_test_renaming_prevnext_str("PREVIOUS")
+
+  @unittest.skipUnless(six.PY3, "Python 3 only")
+  def test_renaming_next_str(self):
+    self.do_test_renaming_prevnext_str("NEXT")
+
+  def do_test_renaming_prevnext_str(self, func):
+    # Given some PREVIOUS/NEXT calls with group_by and order_by, rename columns mentioned there,
+    # and check columns get adjusted and data remains correct.
+    formula = "{}(rec, group_by='Category', order_by='Date')".format(func)
+    renamed_formula = "{}(rec, group_by='cat', order_by='Fecha')".format(func)
+    self.do_test_renames(formula, renamed_formula,
+        calc_expected_pre = functools.partial(self.calc_expected,
+          group_key=(lambda r: r.Category), sort_key=lambda r: SafeSortKey(r.Date),
+          sort_reverse=(func == 'NEXT')
+        ),
+        calc_expected_post = functools.partial(self.calc_expected,
+          group_key=(lambda r: r.cat), sort_key=lambda r: SafeSortKey(r.Fecha),
+          sort_reverse=(func == 'NEXT')
+        ),
+    )
+
+  @unittest.skipUnless(six.PY3, "Python 3 only")
+  def test_renaming_prev_tuple(self):
+    self.do_test_renaming_prevnext_tuple('PREVIOUS')
+
+  @unittest.skipUnless(six.PY3, "Python 3 only")
+  def test_renaming_next_tuple(self):
+    self.do_test_renaming_prevnext_tuple('NEXT')
+
+  def do_test_renaming_prevnext_tuple(self, func):
+    formula = "{}(rec, group_by=('Customer',), order_by=('Category', '-Date'))".format(func)
+    renamed_formula = "{}(rec, group_by=('person',), order_by=('cat', '-Fecha'))".format(func)
+
+    # To handle "-" prefix for Date.
+    class Reverse(object):
+      def __init__(self, key):
+        self.key = key
+      def __lt__(self, other):
+        return other.key < self.key
+
+    self.do_test_renames(formula, renamed_formula,
+        calc_expected_pre = functools.partial(self.calc_expected,
+          group_key=(lambda r: r.Customer),
+          sort_key=lambda r: (SafeSortKey(r.Category), Reverse(SafeSortKey(r.Date))),
+          sort_reverse=(func == 'NEXT')
+        ),
+        calc_expected_post = functools.partial(self.calc_expected,
+          group_key=(lambda r: r.person),
+          sort_key=lambda r: (SafeSortKey(r.cat), Reverse(SafeSortKey(r.Fecha))),
+          sort_reverse=(func == 'NEXT')
+        ),
+    )
+
+  @unittest.skipUnless(six.PY3, "Python 3 only")
+  def test_rank(self):
+    self.do_setup()
+
+    formula = "RANK(rec, group_by='Category', order_by='Date')"
+    self.add_column('Purchases', 'Rank', formula=formula)
+    self.assertTableData('Purchases', cols="subset", data=[
+          [ "id",   "Date",       "Category", "Rank"],
+          [1,       D(2023,12,1), "A",        1     ],
+          [2,       D(2023,12,4), "A",        3     ],
+          [3,       D(2023,12,3), "A",        2     ],
+          [4,       D(2023,12,9), "A",        6     ],
+          [5,       D(2023,12,2), "B",        1     ],
+          [6,       D(2023,12,6), "B",        2     ],
+          [7,       D(2023,12,7), "A",        5     ],
+          [8,       D(2023,12,5), "A",        4     ],
+    ])
+    formula = "RANK(rec, order_by='Date', order='desc')"
+    self.modify_column('Purchases', 'Rank', formula=formula)
+    self.assertTableData('Purchases', cols="subset", data=[
+          [ "id",   "Date",       "Category", "Rank"],
+          [1,       D(2023,12,1), "A",        8     ],
+          [2,       D(2023,12,4), "A",        5     ],
+          [3,       D(2023,12,3), "A",        6     ],
+          [4,       D(2023,12,9), "A",        1     ],
+          [5,       D(2023,12,2), "B",        7     ],
+          [6,       D(2023,12,6), "B",        3     ],
+          [7,       D(2023,12,7), "A",        2     ],
+          [8,       D(2023,12,5), "A",        4     ],
+    ])
+
+  @unittest.skipUnless(six.PY3, "Python 3 only")
+  def test_rank_rename(self):
+    self.do_setup()
+    self.add_column('Purchases', 'Rank',
+        formula="RANK(rec, group_by=\"Category\", order_by='Date')")
+    self.assertTableData('Purchases', cols="subset", data=[
+          [ "id",   "Date",       "Category", "Rank"],
+          [1,       D(2023,12,1), "A",        1     ],
+          [2,       D(2023,12,4), "A",        3     ],
+          [3,       D(2023,12,3), "A",        2     ],
+          [4,       D(2023,12,9), "A",        6     ],
+          [5,       D(2023,12,2), "B",        1     ],
+          [6,       D(2023,12,6), "B",        2     ],
+          [7,       D(2023,12,7), "A",        5     ],
+          [8,       D(2023,12,5), "A",        4     ],
+    ])
+
+    self.apply_user_action(['RenameColumn', 'Purchases', 'Category', 'cat'])
+    self.apply_user_action(['RenameColumn', 'Purchases', 'Date', 'when'])
+
+    renamed_formula = "RANK(rec, group_by=\"cat\", order_by='when')"
+    self.assertTableData('_grist_Tables_column', cols="subset", rows="subset", data=[
+      dict(id=28, colId="Rank", formula=renamed_formula)
+    ])
+    self.assertTableData('Purchases', cols="subset", data=[
+          [ "id",   "when",       "cat",    "Rank"],
+          [1,       D(2023,12,1), "A",        1     ],
+          [2,       D(2023,12,4), "A",        3     ],
+          [3,       D(2023,12,3), "A",        2     ],
+          [4,       D(2023,12,9), "A",        6     ],
+          [5,       D(2023,12,2), "B",        1     ],
+          [6,       D(2023,12,6), "B",        2     ],
+          [7,       D(2023,12,7), "A",        5     ],
+          [8,       D(2023,12,5), "A",        4     ],
+    ])
+
+  @unittest.skipUnless(six.PY3, "Python 3 only")
+  def test_prevnext_rename_result_attr(self):
+    self.do_setup()
+    self.add_column('Purchases', 'PrevAmount', formula="PREVIOUS(rec, order_by=None).Amount")
+    self.add_column('Purchases', 'NextAmount', formula="NEXT(rec, order_by=None).Amount")
+    self.apply_user_action(['RenameColumn', 'Purchases', 'Amount', 'Dollars'])
+    self.assertTableData('_grist_Tables_column', cols="subset", rows="subset", data=[
+      dict(id=28, colId="PrevAmount", formula="PREVIOUS(rec, order_by=None).Dollars"),
+      dict(id=29, colId="NextAmount", formula="NEXT(rec, order_by=None).Dollars"),
+    ])
+
+
+def is_all_ints(array):
+  return all(isinstance(x, int) for x in array)
diff --git a/sandbox/grist/test_sort_key.py b/sandbox/grist/test_sort_key.py
new file mode 100644
index 00000000..b6a6cd67
--- /dev/null
+++ b/sandbox/grist/test_sort_key.py
@@ -0,0 +1,78 @@
+import test_engine
+import testutil
+from sort_key import make_sort_key
+
+class TestSortKey(test_engine.EngineTestCase):
+  def test_sort_key(self):
+    # Set up a table with a few rows.
+    self.load_sample(testutil.parse_test_sample({
+      "SCHEMA": [
+        [1, "Values", [
+          [1, "Date", "Numeric", False, "", "", ""],
+          [2, "Type", "Text", False, "", "", ""],
+        ]],
+      ],
+      "DATA": {
+        "Values": [
+          ["id", "Date",    "Type"],
+          [1,     5,        "a"],
+          [2,     4,        "a"],
+          [3,     5,        "b"],
+        ],
+      }
+    }))
+
+    table = self.engine.tables["Values"]
+    sort_key1 = make_sort_key(table, ("Date", "-Type"))
+    sort_key2 = make_sort_key(table, ("-Date", "Type"))
+    self.assertEqual(sorted([1, 2, 3], key=sort_key1), [2, 3, 1])
+    self.assertEqual(sorted([1, 2, 3], key=sort_key2), [1, 3, 2])
+
+    # Change some values
+    self.update_record("Values", 2, Date=6)
+    self.assertEqual(sorted([1, 2, 3], key=sort_key1), [3, 1, 2])
+    self.assertEqual(sorted([1, 2, 3], key=sort_key2), [2, 1, 3])
+
+
+  def test_column_rename(self):
+    """
+    Make sure that renaming a column to another name and back does not continue using stale
+    references to the deleted column.
+    """
+    # Note that SortedLookupMapColumn does retain references to the columns it uses for sorting,
+    # but lookup columns themselves get deleted and rebuilt in these cases (by mysterious voodoo).
+
+    # Create a simple table (People) with a couple records.
+    self.apply_user_action(["AddTable", "People", [
+      dict(id="Name", type="Text")
+    ]])
+    self.add_record("People", Name="Alice")
+    self.add_record("People", Name="Bob")
+
+    # Create a separate table that does a lookup in the People table.
+    self.apply_user_action(["AddTable", "Test", [
+      dict(id="Lookup1", type="Any", isFormula=True,
+        formula="People.lookupOne(order_by='-Name').Name"),
+      dict(id="Lookup2", type="Any", isFormula=True,
+        formula="People.lookupOne(order_by='Name').Name"),
+      dict(id="Lookup3", type="Any", isFormula=True,
+        formula="People.lookupOne(Name='Bob').Name"),
+    ]])
+    self.add_record("Test")
+
+    # Test that lookups return data as expected.
+    self.assertTableData('Test', cols="subset", data=[
+      dict(id=1, Lookup1="Bob", Lookup2="Alice", Lookup3="Bob")
+    ])
+
+    # Rename a column used for lookups or order_by. Lookup result shouldn't change.
+    self.apply_user_action(["RenameColumn", "People", "Name", "FullName"])
+    self.assertTableData('Test', cols="subset", data=[
+      dict(id=1, Lookup1="Bob", Lookup2="Alice", Lookup3="Bob")
+    ])
+
+    # Rename the column back. Lookup result shouldn't change.
+    self.apply_user_action(["RenameColumn", "People", "FullName", "Name"])
+    self.assertTableData('Test', cols="subset", data=[
+      dict(id=1, Lookup1="Bob", Lookup2="Alice", Lookup3="Bob")
+    ])
diff --git a/sandbox/grist/test_summary_choicelist.py b/sandbox/grist/test_summary_choicelist.py
index 3ac96dbb..fc0bf012 100644
--- a/sandbox/grist/test_summary_choicelist.py
+++ b/sandbox/grist/test_summary_choicelist.py
@@ -145,22 +145,22 @@ class TestSummaryChoiceList(EngineTestCase):
       {
         '#summary#Source_summary_choices1': column.ReferenceListColumn,
         "#lookup#_Contains(value='#summary#Source_summary_choices1', match_empty=no_match_empty)":
-          lookup.ContainsLookupMapColumn,
+          lookup.LookupMapColumn,
         '#summary#Source_summary_choices1_choices2': column.ReferenceListColumn,
         "#lookup#_Contains(value='#summary#Source_summary_choices1_choices2', "
         "match_empty=no_match_empty)":
-          lookup.ContainsLookupMapColumn,
+          lookup.LookupMapColumn,
 
         # simple summary and lookup
         '#summary#Source_summary_other': column.ReferenceColumn,
-        '#lookup##summary#Source_summary_other': lookup.SimpleLookupMapColumn,
+        '#lookup##summary#Source_summary_other': lookup.LookupMapColumn,
 
         '#summary#Source_summary_choices1_other': column.ReferenceListColumn,
         "#lookup#_Contains(value='#summary#Source_summary_choices1_other', "
         "match_empty=no_match_empty)":
-          lookup.ContainsLookupMapColumn,
+          lookup.LookupMapColumn,
 
-        "#lookup#": lookup.SimpleLookupMapColumn,
+        "#lookup#": lookup.LookupMapColumn,
       }
     )
 
diff --git a/sandbox/grist/test_temp_rowids.py b/sandbox/grist/test_temp_rowids.py
index 7c2cbb4e..a9a11ee3 100644
--- a/sandbox/grist/test_temp_rowids.py
+++ b/sandbox/grist/test_temp_rowids.py
@@ -86,3 +86,24 @@ class TestTempRowIds(test_engine.EngineTestCase):
           "schoolCities": ["E:C", "B:New York", "E:C", "B:New York", "B:New York"]}],
       ]
     })
+
+  def test_update_remove(self):
+    self.load_sample(testsamples.sample_students)
+
+    out_actions = self.engine.apply_user_actions([useractions.from_repr(ua) for ua in (
+      ['AddRecord', 'Students', -1, {'firstName': 'A'}],
+      ['UpdateRecord', 'Students', -1, {'lastName': 'A'}],
+      ['BulkAddRecord', 'Students', [-2, None, -3], {'firstName': ['C', 'D', 'E']}],
+      ['BulkUpdateRecord', 'Students', [-2, -3, -1], {'lastName': ['C', 'E', 'F']}],
+      ['RemoveRecord', 'Students', -2],
+    )])
+
+    self.assertPartialOutActions(out_actions, {
+      "stored": [
+        ['AddRecord', 'Students', 7, {'firstName': 'A'}],
+        ['UpdateRecord', 'Students', 7, {'lastName': 'A'}],
+        ['BulkAddRecord', 'Students', [8, 9, 10], {'firstName': ['C', 'D', 'E']}],
+        ['BulkUpdateRecord', 'Students', [8, 10, 7], {'lastName': ['C', 'E', 'F']}],
+        ['RemoveRecord', 'Students', 8],
+      ]
+    })
diff --git a/sandbox/grist/testutil.py b/sandbox/grist/testutil.py
index 8ae899c6..a34f8901 100644
--- a/sandbox/grist/testutil.py
+++ b/sandbox/grist/testutil.py
@@ -19,6 +19,17 @@ def table_data_from_rows(table_id, col_names, rows):
   return actions.TableData(table_id, column_values.pop('id'), column_values)
 
 
+def table_data_from_row_dicts(table_id, row_dict_list):
+  """
+  Returns a TableData object built from table_id and a list of dictionaries, one per row, mapping
+  column names to cell values.
+  """
+  col_ids = {'id': None}    # Collect the set of col_ids. Use a dict for predictable order.
+  for row in row_dict_list:
+    col_ids.update({c: None for c in row})
+  column_values = {col: [row.get(col) for row in row_dict_list] for col in col_ids}
+  return actions.TableData(table_id, column_values.pop('id'), column_values)
+
 
 def parse_testscript(script_path=None):
   """
diff --git a/sandbox/grist/twowaymap.py b/sandbox/grist/twowaymap.py
index 3b0edaa8..13b06e38 100644
--- a/sandbox/grist/twowaymap.py
+++ b/sandbox/grist/twowaymap.py
@@ -238,6 +238,28 @@ def _set_remove(container, value):
 
 register_container(set, _set_make, _set_add, _set_remove)
 
+# A version of `set` that maintains also sorted versions of the set. Used in lookups, to cache the
+# sorted lookup results.
+class LookupSet(set):
+  def __init__(self, iterable=[]):
+    super(LookupSet, self).__init__(list(iterable))
+    self.sorted_versions = {}
+
+def _LookupSet_make(value):
+  return LookupSet([value])
+def _LookupSet_add(container, value):
+  if value not in container:
+    container.add(value)
+    container.sorted_versions.clear()
+    return True
+  return False
+def _LookupSet_remove(container, value):
+  if value in container:
+    container.discard(value)
+    container.sorted_versions.clear()
+
+register_container(LookupSet, _LookupSet_make, _LookupSet_add, _LookupSet_remove)
+
 
 # Allow `list` to be used as a bin type.
 def _list_make(value):
diff --git a/sandbox/grist/usertypes.py b/sandbox/grist/usertypes.py
index d599a222..b52d0353 100644
--- a/sandbox/grist/usertypes.py
+++ b/sandbox/grist/usertypes.py
@@ -457,7 +457,8 @@ class ReferenceList(BaseColumnType):
 
     if isinstance(value, RecordSet):
       assert value._table.table_id == self.table_id
-      return objtypes.RecordList(value._row_ids, group_by=value._group_by, sort_by=value._sort_by)
+      return objtypes.RecordList(value._row_ids, group_by=value._group_by, sort_by=value._sort_by,
+          sort_key=value._sort_key)
     elif not value:
       # Represent an empty ReferenceList as None (also its default value). Formulas will see [].
       return None
@@ -465,8 +466,15 @@ class ReferenceList(BaseColumnType):
 
   @classmethod
   def is_right_type(cls, value):
-    return value is None or (isinstance(value, list) and
-                             all(Reference.is_right_type(val) for val in value))
+    # TODO: whenever is_right_type isn't trivial, get_cell_value should just remember the result
+    # rather than recompute it on every access. Actually this applies not only to is_right_type
+    # but to everything get_cell_value does. It should use minimal-memory minimal-overhead
+    # translations of raw->rich for valid values, and use what memory it needs but still guarantee
+    # constant time for invalid values.
+    return (value is None or
+        (isinstance(value, objtypes.RecordList)) or
+        (isinstance(value, list) and all(Reference.is_right_type(val) for val in value))
+    )
 
 
 class Attachments(ReferenceList):

From db52bb0082d035e80642012be1e2cd4d3cc07c05 Mon Sep 17 00:00:00 2001
From: Paul Fitzpatrick <paulfitz@alum.mit.edu>
Date: Wed, 17 Jul 2024 10:24:41 -0400
Subject: [PATCH 059/145] (core) move test/server/lib/GranularAccess.ts to core

Summary:
Move an important set of tests that were in our SaaS
repo for no good reason.

Test Plan: moving tests

Reviewers: jordigh

Reviewed By: jordigh

Differential Revision: https://phab.getgrist.com/D4300
---
 test/server/lib/GranularAccess.ts | 4085 +++++++++++++++++++++++++++++
 1 file changed, 4085 insertions(+)
 create mode 100644 test/server/lib/GranularAccess.ts

diff --git a/test/server/lib/GranularAccess.ts b/test/server/lib/GranularAccess.ts
new file mode 100644
index 00000000..1748df21
--- /dev/null
+++ b/test/server/lib/GranularAccess.ts
@@ -0,0 +1,4085 @@
+import {LocalActionBundle, SandboxActionBundle} from 'app/common/ActionBundle';
+import {PermissionDataWithExtraUsers} from 'app/common/ActiveDocAPI';
+import {delay} from 'app/common/delay';
+import {
+  AddRecord,
+  BulkAddRecord,
+  BulkRemoveRecord,
+  BulkUpdateRecord,
+  CellValue,
+  DocAction,
+  RemoveRecord,
+  ReplaceTableData,
+  TableColValues,
+  TableDataAction,
+  UpdateRecord
+} from 'app/common/DocActions';
+import {OpenDocOptions} from 'app/common/DocListAPI';
+import {SHARE_KEY_PREFIX} from 'app/common/gristUrls';
+import {isLongerThan, pruneArray} from 'app/common/gutil';
+import {UserAPI, UserAPIImpl} from 'app/common/UserAPI';
+import {GristObjCode} from 'app/plugin/GristData';
+import {Deps as DocClientsDeps} from 'app/server/lib/DocClients';
+import {DocManager} from 'app/server/lib/DocManager';
+import {makeExceptionalDocSession} from 'app/server/lib/DocSession';
+import {filterColValues, GranularAccess} from 'app/server/lib/GranularAccess';
+import {globalUploadSet} from 'app/server/lib/uploads';
+import {assert} from 'chai';
+import {cloneDeep, isMatch} from 'lodash';
+import * as sinon from 'sinon';
+import {TestServer} from 'test/gen-server/apiUtils';
+import {createDocTools} from 'test/server/docTools';
+import {GristClient, openClient} from 'test/server/gristClient';
+import * as testUtils from 'test/server/testUtils';
+
+describe('GranularAccess', function() {
+  this.timeout(60000);
+  let home: TestServer;
+  testUtils.setTmpLogLevel('error');
+  let owner: UserAPI;
+  let editor: UserAPI;
+  let docId: string;
+  let wsId: number;
+  let cliOwner: GristClient;
+  let cliEditor: GristClient;
+  let docManager: DocManager;
+  const docTools = createDocTools();
+  const sandbox = sinon.createSandbox();
+
+  async function getWebsocket(api: UserAPI) {
+    const who = await api.getSessionActive();
+    return openClient(home.server, who.user.email, who.org?.domain || 'docs');
+  }
+
+  /**
+   * Add some actions directly into document history, so they can be used as an undo.
+   */
+  async function addFakeBundle(actions: DocAction[],
+                               options?: {
+                                 user?: string,
+                                 time?: number,
+                               }) {
+    const doc = await docManager.getActiveDoc(docId);
+    const history = doc?.getActionHistory();
+    const actionNum = fakeActionNum;
+    const actionHash = String(fakeActionNum);
+    fakeActionNum++;
+    const bundle: LocalActionBundle = {
+      actionNum,
+      actionHash,
+      parentActionHash: null,
+      userActions: actions,
+      undo: actions,
+      info: [0, {time: Date.now(), ...options} as any],
+      stored: [],
+      calc: [],
+      envelopes: []
+    };
+    await history?.recordNextShared(bundle);
+    return { actionNum, actionHash };
+  }
+  let fakeActionNum = 10000;
+
+  /**
+   * Apply actions as a fake undo, inserting them in history and then activating
+   * them from there.
+   */
+  async function applyAsUndo(client: GristClient, actions: DocAction[],
+                             options?: {
+                               user?: string,
+                               time?: number,
+                             }) {
+    const {actionNum, actionHash} = await addFakeBundle(actions, options);
+    const result = await client.send("applyUserActionsById", 0, [actionNum], [actionHash], true);
+    return result;
+  }
+
+  async function getShareKeyForUrl(linkId: string) {
+    const shares = await home.dbManager.connection.query(
+      'select * from shares where link_id = ?', [linkId]);
+    const key = shares[0].key;
+    if (!key) {
+      throw new Error('cannot find share key');
+    }
+    return `${SHARE_KEY_PREFIX}${key}`;
+  }
+
+  async function removeShares(sharingDocId: string, api: UserAPI) {
+    const shares = await owner.getDocAPI(sharingDocId).getRecords('_grist_Shares');
+    for (const share of shares) {
+      await api.applyUserActions(docId, [
+        ['RemoveRecord', '_grist_Shares', share.id]
+      ]);
+    }
+  }
+
+  before(async function() {
+    home = new TestServer(this);
+    await home.start(['home', 'docs']);
+    const api = await home.createHomeApi('chimpy', 'docs', true);
+    await api.newOrg({name: 'testy', domain: 'testy'});
+    owner = await home.createHomeApi('chimpy', 'testy', true);
+    wsId = await owner.newWorkspace({name: 'ws'}, 'current');
+    await owner.updateWorkspacePermissions(wsId, {
+      users: {
+        'kiwi@getgrist.com': 'owners',
+        'charon@getgrist.com': 'editors',
+      }
+    });
+    editor = await home.createHomeApi('charon', 'testy', true);
+    docManager = (home.server as any)._docManager;
+  });
+
+  after(async function() {
+    const api = await home.createHomeApi('chimpy', 'docs');
+    await api.deleteOrg('testy');
+    await home.stop();
+    await globalUploadSet.cleanupAll();
+  });
+
+  afterEach(async function() {
+    if (docId) {
+      for (const cli of [cliEditor, cliOwner]) {
+        await closeClient(cli);
+      }
+      docId = "";
+    }
+    sandbox.restore();
+  });
+
+  async function getGranularAccess(): Promise<GranularAccess> {
+    const doc = await docManager.getActiveDoc(docId);
+    return (doc as any)._granularAccess;
+  }
+
+  async function freshDoc(fixture?: string) {
+    docId = await owner.newDoc({name: 'doc'}, wsId);
+    if (fixture) {
+      await home.copyFixtureDoc(fixture, docId);
+      await owner.getDocAPI(docId).forceReload();
+    }
+    cliEditor = await getWebsocket(editor);
+    cliOwner = await getWebsocket(owner);
+    await cliEditor.openDocOnConnect(docId);
+    await cliOwner.openDocOnConnect(docId);
+  }
+
+  // Reopen clients in a different mode (e.g. default vs fork), or in a different order
+  // (editor first or owner first).
+  async function reopenClients(options?: OpenDocOptions & {
+    first?: 'owner' | 'editor' | 'any',
+  }) {
+    cliEditor.flush();
+    cliOwner.flush();
+    await cliEditor.send("closeDoc", 0);
+    await cliOwner.send("closeDoc", 0);
+    const order = options?.first === 'owner' ? [cliOwner, cliEditor] : [cliEditor, cliOwner];
+    await order[0].send("openDoc", docId, options);
+    if (options?.first && options.first !== 'any') {
+      await delay(250);
+    }
+    await order[1].send("openDoc", docId, options);
+  }
+
+  // See the comment in PermissionInfo.ts/evaluateRule() for why we need this.
+  describe("forces a row check for rules with memo and rec", function() {
+
+    it('for -U permission', async function() {
+      await memoDoc();
+      await owner.applyUserActions(docId, [
+        ['AddRecord', '_grist_ACLResources', -2, {tableId: 'Table1', colIds: '*'}],
+        ['AddRecord', '_grist_ACLRules', null, {
+          resource: -2, aclFormula: 'user.Access == OWNER', permissionsText: 'all',  // Owner can do anything
+        }],
+        ['AddRecord', '_grist_ACLRules', null, {
+          resource: -2, aclFormula: 'rec.A == 1', permissionsText: '-U'
+        }],
+        ['AddRecord', '_grist_ACLRules', null, {
+          resource: -2, aclFormula: 'rec.A == 2', permissionsText: '-U', memo: 'Cant2',  // Can't update 2
+        }],
+        ['AddRecord', '_grist_ACLRules', null, {
+          resource: -2, aclFormula: 'rec.A == 3', permissionsText: '-U', memo: 'Cant3',  // Can't update 3
+        }],
+        ['AddRecord', '_grist_ACLRules', null, {
+          resource: -2, aclFormula: '', permissionsText: '-U',  // Actually can't update anything
+        }],
+      ]);
+
+      // Make sure we see correct memo.
+      await assertDeniedFor(editor.getDocAPI(docId).updateRows('Table1', {id: [1], A: [100]}), []);
+      await assertDeniedFor(editor.getDocAPI(docId).updateRows('Table1', {id: [2], A: [100]}), ['Cant2']);
+      await assertDeniedFor(editor.getDocAPI(docId).updateRows('Table1', {id: [3], A: [100]}), ['Cant3']);
+      await assertDeniedFor(editor.getDocAPI(docId).updateRows('Table1', {id: [4], A: [100]}), []);
+    });
+
+    it('for -C permission', async function() {
+      // Check atomic permission UCD
+      await memoDoc();
+      await owner.applyUserActions(docId, [
+        ['AddRecord', '_grist_ACLResources', -1, {tableId: 'Table1', colIds: '*'}],
+        ['AddRecord', '_grist_ACLRules', null, {
+          resource: -1, aclFormula: 'user.Access == OWNER', permissionsText: 'all',  // Owner can do anything
+        }],
+        ['AddRecord', '_grist_ACLRules', null, {
+          resource: -1, aclFormula: 'rec.A == 1', permissionsText: '-C' // Can't create rec.A
+        }],
+        ['AddRecord', '_grist_ACLRules', null, {
+          resource: -1, aclFormula: 'rec.A == 2', permissionsText: '-C', memo: 'Cant2',  // Can't create rec with 2
+        }],
+        ['AddRecord', '_grist_ACLRules', null, {
+          resource: -1, aclFormula: '', permissionsText: '-C',  // Actually can't createy anything
+        }],
+      ]);
+
+      // Make sure we see correct memo.
+      await assertDeniedFor(editor.getDocAPI(docId).addRows('Table1', {A: [1]}), []);
+      await assertDeniedFor(editor.getDocAPI(docId).addRows('Table1', {A: [2]}), ['Cant2']);
+      await assertDeniedFor(editor.getDocAPI(docId).addRows('Table1', {A: [3]}), []);
+    });
+
+    it('for -D permission', async function() {
+      // Check atomic permission UCD
+      await memoDoc();
+      await owner.applyUserActions(docId, [
+        ['AddRecord', '_grist_ACLResources', -1, {tableId: 'Table1', colIds: '*'}],
+        ['AddRecord', '_grist_ACLRules', null, {
+          resource: -1, aclFormula: 'user.Access == OWNER', permissionsText: 'all',  // Owner can do anything
+        }],
+        ['AddRecord', '_grist_ACLRules', null, {
+          resource: -1, aclFormula: 'rec.A == 1', permissionsText: '-D' // Can't remove 1
+        }],
+        ['AddRecord', '_grist_ACLRules', null, {
+          resource: -1, aclFormula: 'rec.A == 2', permissionsText: '-D', memo: 'Cant2',  // Can't remove 2 (with memo)
+        }],
+        ['AddRecord', '_grist_ACLRules', null, {
+          resource: -1, aclFormula: '', permissionsText: '-D',  // Actually can't remove anything.
+        }],
+      ]);
+
+      // Make sure we see correct memo.
+      await assertDeniedFor(editor.getDocAPI(docId).removeRows('Table1', [1]), []);
+      await assertDeniedFor(editor.getDocAPI(docId).removeRows('Table1', [2]), ['Cant2']);
+      await assertDeniedFor(editor.getDocAPI(docId).removeRows('Table1', [3]), []);
+    });
+
+    it('for -U with mixed columns', async function() {
+      // Check atomic permission UCD
+      await memoDoc();
+      await owner.applyUserActions(docId, [
+        ['AddRecord', '_grist_ACLResources', -1, {tableId: 'Table1', colIds: 'A'}],
+        ['AddRecord', '_grist_ACLRules', null, {
+          resource: -1, aclFormula: 'user.Access == OWNER', permissionsText: 'all',  // Owner can do anything
+        }],
+        ['AddRecord', '_grist_ACLRules', null, {
+          resource: -1, aclFormula: 'rec.A == 1', permissionsText: '-U' // Can't update 1
+        }],
+        ['AddRecord', '_grist_ACLRules', null, {
+          resource: -1, aclFormula: 'rec.A == 2', permissionsText: '-U', memo: 'Cant2',  // Can't update 2 (with memo)
+        }],
+        ['AddRecord', '_grist_ACLRules', null, {
+          resource: -1, aclFormula: 'rec.A == 3', permissionsText: '-U', memo: 'Cant3',
+        }],
+        ['AddRecord', '_grist_ACLRules', null, {
+          resource: -1, aclFormula: '', permissionsText: '-U',  // Actually can't update this column at all.
+        }],
+      ]);
+
+      // Make sure we see correct memo.
+      await assertDeniedFor(editor.getDocAPI(docId).updateRows('Table1', {id: [1], A: [100]}), []);
+      await assertDeniedFor(editor.getDocAPI(docId).updateRows('Table1', {id: [2], A: [100]}), ['Cant2']);
+      await assertDeniedFor(editor.getDocAPI(docId).updateRows('Table1', {id: [3], A: [100]}), ['Cant3']);
+      await assertDeniedFor(editor.getDocAPI(docId).updateRows('Table1', {id: [4], A: [100]}), []);
+
+      // But B is ok to update.
+      await assert.isFulfilled(editor.getDocAPI(docId).updateRows('Table1', {id: [1], B: [100]}));
+      await assert.isFulfilled(editor.getDocAPI(docId).updateRows('Table1', {id: [2], B: [100]}));
+      await assert.isFulfilled(editor.getDocAPI(docId).updateRows('Table1', {id: [3], B: [100]}));
+      await assert.isFulfilled(editor.getDocAPI(docId).updateRows('Table1', {id: [4], B: [100]}));
+    });
+
+    it('for -U with mixed columns with default fallback', async function() {
+      await memoDoc();
+      await owner.applyUserActions(docId, [
+        ['AddRecord', '_grist_ACLResources', -1, {tableId: 'Table1', colIds: 'A'}],
+        ['AddRecord', '_grist_ACLResources', -2, {tableId: 'Table1', colIds: '*'}],
+        ['AddRecord', '_grist_ACLRules', null, {
+          resource: -1, aclFormula: 'user.Access == OWNER', permissionsText: 'all',  // Owner can do anything
+        }],
+        //######### A column rules
+        ['AddRecord', '_grist_ACLRules', null, {
+          resource: -1, aclFormula: 'rec.A == 1', permissionsText: '-U'
+        }],
+        ['AddRecord', '_grist_ACLRules', null, {
+          resource: -1, aclFormula: 'rec.A == 2', permissionsText: '-U', memo: 'Cant2',  // Can't update 2 (with memo)
+        }],
+        ['AddRecord', '_grist_ACLRules', null, {
+          resource: -1, aclFormula: 'rec.A == 3', permissionsText: '-U', memo: 'Cant3',
+        }],
+        // ######## Table rules (default)
+        ['AddRecord', '_grist_ACLRules', null, {
+          resource: -2, aclFormula: 'rec.A == 4', permissionsText: '-U', memo: 'Cant4',  // Row 4 is read only.
+        }],
+        ['AddRecord', '_grist_ACLRules', null, {
+          resource: -2, aclFormula: '', permissionsText: '-U', memo: 'no', // Actually can't update this table at all.
+        }]
+      ]);
+
+      // Make sure we see correct memo.
+      await assertDeniedFor(editor.getDocAPI(docId).updateRows('Table1', {id: [1], A: [100]}), ['no']);
+      await assertDeniedFor(editor.getDocAPI(docId).updateRows('Table1', {id: [2], A: [100]}), ['Cant2', 'no']);
+      await assertDeniedFor(editor.getDocAPI(docId).updateRows('Table1', {id: [3], A: [100]}), ['Cant3', 'no']);
+      await assertDeniedFor(editor.getDocAPI(docId).updateRows('Table1', {id: [4], A: [100]}), ['Cant4', 'no']);
+      await assertDeniedFor(editor.getDocAPI(docId).updateRows('Table1', {id: [4], B: [100]}), ['Cant4', 'no']);
+      await assertDeniedFor(editor.getDocAPI(docId).updateRows('Table1', {id: [5], A: [100]}), ['no']);
+      await assertDeniedFor(editor.getDocAPI(docId).updateRows('Table1', {id: [5], B: [100]}), ['no']);
+    });
+
+    it('for -U with mixed columns with default fallback', async function() {
+      await memoDoc();
+      await owner.applyUserActions(docId, [
+        ['AddRecord', '_grist_ACLResources', -1, {tableId: 'Table1', colIds: 'A'}],
+        ['AddRecord', '_grist_ACLResources', -2, {tableId: 'Table1', colIds: '*'}],
+        ['AddRecord', '_grist_ACLRules', null, {
+          resource: -1, aclFormula: 'user.Access == OWNER', permissionsText: 'all',  // Owner can do anything
+        }],
+        //######### A column rules
+        ['AddRecord', '_grist_ACLRules', null, {
+          resource: -1, aclFormula: 'rec.A == 1', permissionsText: '-U'
+        }],
+        ['AddRecord', '_grist_ACLRules', null, {
+          resource: -1, aclFormula: 'rec.A == 2', permissionsText: '-U'  // Can't update 2 (with memo)
+        }],
+        ['AddRecord', '_grist_ACLRules', null, {
+          resource: -1, aclFormula: 'rec.A == 3', permissionsText: '-U'
+        }],
+        // ######## Table rules (default)
+        ['AddRecord', '_grist_ACLRules', null, {
+          resource: -2, aclFormula: 'rec.A == 4', permissionsText: '-U'  // Row 4 is read only.
+        }],
+        ['AddRecord', '_grist_ACLRules', null, {
+          resource: -2, aclFormula: '', permissionsText: '-U', memo: 'no', // Actually can't update this table at all.
+        }]
+      ]);
+
+      // Make sure we see correct memo.
+      await assertDeniedFor(editor.getDocAPI(docId).updateRows('Table1', {id: [1], A: [100]}), ['no']);
+      await assertDeniedFor(editor.getDocAPI(docId).updateRows('Table1', {id: [2], A: [100]}), ['no']);
+      await assertDeniedFor(editor.getDocAPI(docId).updateRows('Table1', {id: [3], A: [100]}), ['no']);
+      await assertDeniedFor(editor.getDocAPI(docId).updateRows('Table1', {id: [4], A: [100]}), ['no']);
+      await assertDeniedFor(editor.getDocAPI(docId).updateRows('Table1', {id: [4], B: [100]}), ['no']);
+      await assertDeniedFor(editor.getDocAPI(docId).updateRows('Table1', {id: [5], A: [100]}), ['no']);
+      await assertDeniedFor(editor.getDocAPI(docId).updateRows('Table1', {id: [5], B: [100]}), ['no']);
+    });
+
+    it('for -U with mixed columns without default fallback', async function() {
+      await memoDoc();
+      await owner.applyUserActions(docId, [
+        ['AddRecord', '_grist_ACLResources', -1, {tableId: 'Table1', colIds: 'A'}],
+        ['AddRecord', '_grist_ACLResources', -2, {tableId: 'Table1', colIds: '*'}],
+        ['AddRecord', '_grist_ACLRules', null, {
+          resource: -1, aclFormula: 'user.Access == OWNER', permissionsText: 'all',  // Owner can do anything
+        }],
+        //######### A column rules
+        ['AddRecord', '_grist_ACLRules', null, {
+          resource: -1, aclFormula: 'rec.A == 1', permissionsText: '-U'
+        }],
+        ['AddRecord', '_grist_ACLRules', null, {
+          resource: -1, aclFormula: 'rec.A == 2', permissionsText: '-U', memo: 'Cant2',  // Can't update 2 (with memo)
+        }],
+        ['AddRecord', '_grist_ACLRules', null, {
+          resource: -1, aclFormula: 'rec.A == 3', permissionsText: '-U', memo: 'Cant3',
+        }],
+        // ######## Table rules (default)
+        ['AddRecord', '_grist_ACLRules', null, {
+          resource: -2, aclFormula: 'rec.A == 4', permissionsText: '-U', memo: 'Cant4',  // Row 4 is read only.
+        }],
+      ]);
+
+      // Make sure we see correct memo.
+      await assertDeniedFor(editor.getDocAPI(docId).updateRows('Table1', {id: [1], A: [100]}), []);
+      await assertDeniedFor(editor.getDocAPI(docId).updateRows('Table1', {id: [2], A: [100]}), ['Cant2']);
+      await assertDeniedFor(editor.getDocAPI(docId).updateRows('Table1', {id: [3], A: [100]}), ['Cant3']);
+      await assertDeniedFor(editor.getDocAPI(docId).updateRows('Table1', {id: [4], A: [100]}), ['Cant4']);
+      await assertDeniedFor(editor.getDocAPI(docId).updateRows('Table1', {id: [4], B: [100]}), ['Cant4']);
+
+      await assert.isFulfilled(editor.getDocAPI(docId).updateRows('Table1', {id: [5], A: [100]}));
+      await assert.isFulfilled(editor.getDocAPI(docId).updateRows('Table1', {id: [5], B: [100]}));
+    });
+
+    async function memoDoc() {
+      await freshDoc();
+      await owner.applyUserActions(docId, [
+        ['AddTable', 'Table1', [{id: 'A', type: 'Int'}, {id: 'B', type: 'Int'}]],
+        ['AddRecord', '_grist_ACLResources', -1, {tableId: '*', colIds: '*'}],
+        ['AddRecord', '_grist_ACLRules', null, {
+          resource: -1, aclFormula: 'user.Access != OWNER', permissionsText: '-S',  // drop schema rights
+        }],
+      ]);
+      cliEditor.flush();
+      cliOwner.flush();
+      await owner.getDocAPI(docId).addRows('Table1', {A: [1, 2, 3, 4, 5]});
+    }
+  });
+
+  it('hides transform columns from users without SCHEMA_EDIT when any column has rules', async () => {
+    // gristHelper_Converted and gristHelper_Transform columns are special. When a document
+    // has a granular access rules, those columns are hidden from users without SCHEMA_EDIT.
+    await applyTransformation('B');
+    // Make sure we don't see transform columns as editor.
+    assert.deepEqual((await cliEditor.readDocUserAction()), [
+      ['AddRecord', '_grist_Tables_column', 8, {
+        isFormula: false, type: 'Any', formula: '', colId: '', widgetOptions: '',
+        label: '', parentPos: 8, parentId: 0
+      }],
+      ['AddRecord', '_grist_Tables_column', 9, {
+        isFormula: true, type: 'Any', formula: '', colId: '', widgetOptions: '',
+        label: '', parentPos: 9, parentId: 0
+      }],
+      ['ModifyColumn', 'Table1', 'A', {type: 'Text'}],
+      ['UpdateRecord', 'Table1', 1, {A: '1234' }],
+      ['UpdateRecord', '_grist_Tables_column', 2, {widgetOptions: '{}', type: 'Text'}]
+    ]);
+  });
+
+  it('hides transform columns from users without SCHEMA_EDIT if column has rules', async () => {
+    await applyTransformation('A');
+    // Make sure we don't see anything as editor (we hid column A).
+    assert.deepEqual((await cliEditor.readDocUserAction()), [
+      ['AddRecord', '_grist_Tables_column', 8, {
+        isFormula: false, type: 'Any', formula: '', colId: '', widgetOptions: '',
+        label: '', parentPos: 8, parentId: 0
+      }],
+      ['AddRecord', '_grist_Tables_column', 9, {
+        isFormula: true, type: 'Any', formula: '', colId: '', widgetOptions: '',
+        label: '', parentPos: 9, parentId: 0
+      }],
+      ['UpdateRecord', '_grist_Tables_column', 2, {widgetOptions: '', type: 'Any'}]
+    ]);
+  });
+
+  async function applyTransformation(colToHide: string) {
+    await freshDoc();
+    await owner.applyUserActions(docId, [
+      ['AddTable', 'Table1', [{id: 'A', type: 'Int'}, {id: 'B', type: 'Int'}]],
+      ['AddRecord', '_grist_ACLResources', -1, {tableId: '*', colIds: '*'}],
+      ['AddRecord', '_grist_ACLResources', -2, {tableId: 'Table1', colIds: colToHide}],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -1, aclFormula: 'user.Access != OWNER', permissionsText: '-S',  // drop schema rights
+      }],
+      ['AddRecord', '_grist_ACLRules', null, {
+        // Transform columns are only hidden from non-owners when we have a granular access rules.
+        // Here we will hide either column A (which will be transformed) or column B (which is not relevant
+        // but will trigger ACL check).
+        resource: -2, aclFormula: 'user.Access != OWNER', permissionsText: '-R',
+      }],
+      ['AddRecord', 'Table1', null, {A: 1234}],
+    ]);
+    cliEditor.flush();
+    cliOwner.flush();
+
+    // Make transformation as owner. This mimics what happens when we apply a transformation using UI (when
+    // we change column type from Number to Text).
+    await owner.applyUserActions(docId, [
+      ['AddColumn', 'Table1', 'gristHelper_Converted', {type: 'Text', isFormula: false, visibleCol: 0, formula: ''}],
+      ['AddColumn', 'Table1', 'gristHelper_Transform',
+        {type: 'Text', isFormula: true, visibleCol: 0, formula: 'rec.gristHelper_Converted'}],
+      // This action is repeated by the UI just before applying (we don't to repeat it here).
+      ["ConvertFromColumn", "Table1", "A", "gristHelper_Converted", "Text", "", 0],
+      ["CopyFromColumn", "Table1", "gristHelper_Transform", "A", "{}"],
+    ]);
+
+    // Make sure we see the actions as owner.
+    assert.deepEqual(await cliOwner.readDocUserAction(), [
+      ['AddColumn', 'Table1', 'gristHelper_Converted', {isFormula: false, type: 'Text', formula: ''}],
+      ['AddRecord', '_grist_Tables_column', 8, {
+        isFormula: false,
+        type: 'Text',
+        formula: '',
+        colId: 'gristHelper_Converted',
+        widgetOptions: '',
+        label: 'gristHelper_Converted',
+        parentPos: 8,
+        parentId: 1
+      }],
+      ['AddColumn', 'Table1', 'gristHelper_Transform', {
+        isFormula: true,
+        type: 'Text',
+        formula: 'rec.gristHelper_Converted'
+      }],
+      ['AddRecord', '_grist_Tables_column', 9, {
+        isFormula: true,
+        type: 'Text',
+        formula: 'rec.gristHelper_Converted',
+        colId: 'gristHelper_Transform',
+        widgetOptions: '',
+        label: 'gristHelper_Transform',
+        parentPos: 9,
+        parentId: 1
+      }],
+      ['UpdateRecord', 'Table1', 1, {gristHelper_Converted: '1234'}],
+      ['ModifyColumn', 'Table1', 'A', {type: 'Text'}],
+      ['UpdateRecord', 'Table1', 1, {A: '1234'}],
+      ['UpdateRecord', '_grist_Tables_column', 2, {type: 'Text', widgetOptions: '{}'}],
+      ['UpdateRecord', 'Table1', 1, {gristHelper_Transform: '1234'}]
+    ]);
+  }
+
+  it('persist data when action is rejected', async () => {
+    await freshDoc();
+    await owner.applyUserActions(docId, [
+      ['AddTable', 'Table1', [{id: 'A'}, {id: 'B'}]],
+      ['AddRecord', 'Table1', null, {B: 1}],
+      ['ModifyColumn', 'Table1', 'B', { isFormula: false, type: 'Text' }],
+      ['ModifyColumn', 'Table1', 'A', { formula: 'UUID() + $B', isFormula: true }],
+      ['AddRecord', '_grist_ACLResources', -1, {tableId: 'Table1', colIds: '*'}],
+      ['AddRecord', '_grist_ACLRules', null, {
+        // User can't change column B to 2
+        resource: -1,
+        aclFormula: 'newRec.B == "2"',
+        permissionsText: '-U',
+        memo: 'stop',
+      }],
+    ]);
+    // Read A from the engine
+    const aMemBefore = await memCell('Table1', 'A', 1);
+    // Read A from database.
+    const aDbBefore = await dbCell('Table1', 'A', 1);
+    assert.equal(aMemBefore, aDbBefore);
+    // Trigger rejection.
+    await assertDeniedFor(owner.getDocAPI(docId).updateRows('Table1', {id: [1], B: ['2']}), ['stop']);
+    // Read A value again.
+    const aDbAfter = await dbCell('Table1', 'A', 1);
+    // Now read A value from the engine.
+    const aMemAfter = await memCell('Table1', 'A', 1);
+    assert.equal(aMemAfter, aDbAfter);
+    assert.notEqual(aMemAfter, aMemBefore);
+  });
+
+  it('persist data when action is rejected with newRec.A != rec.A formula', async () => {
+    // Create another example with a different formula.
+    await freshDoc();
+    await owner.applyUserActions(docId, [
+      ['AddTable', 'Table1', [{id: 'A'}, {id: 'B'}]],
+      ['AddRecord', 'Table1', null, {B: 1}],
+      ['ModifyColumn', 'Table1', 'B', { isFormula: false, type: 'Int' }],
+      ['ModifyColumn', 'Table1', 'A', { formula: 'UUID() if $B else UUID()', isFormula: true }],
+      ['AddRecord', '_grist_ACLResources', -1, {tableId: 'Table1', colIds: '*'}],
+      ['AddRecord', '_grist_ACLRules', null, {
+        // We can't trigger A change as it will always have a different value.
+        // It looks like we can't reject this action, as it will cause a fatal failure,
+        // but this is indirect action, so it will bypass ACL check.
+        resource: -1,
+        aclFormula: 'newRec.A != rec.A',
+        permissionsText: '-U',
+        memo: 'stop',
+      }],
+    ]);
+
+    const aMemBefore = await memCell('Table1', 'A', 1);
+    const aDbBefore = await dbCell('Table1', 'A', 1);
+    await assertDeniedFor(editor.getDocAPI(docId).updateRows('Table1', {id: [1], B: [2]}), ['stop']);
+    const aMemAfter = await memCell('Table1', 'A', 1);
+    const aDbAfter = await dbCell('Table1', 'A', 1);
+    assert.equal(aMemAfter, aDbAfter);
+    assert.equal(aMemBefore, aDbBefore);
+    assert.notEqual(aDbBefore, aDbAfter);
+    assert.notEqual(aMemBefore, aMemAfter);
+
+    // Make sure we can update formula, as a value change it's not a direct action.
+    await assert.isFulfilled(editor.applyUserActions(docId, [
+      ['ModifyColumn', 'Table1', 'A', { formula: 'UUID() + "test"'}],
+    ]));
+  });
+
+  it('persist data when action is rejected with schema action', async () => {
+    // Reject schema actions
+    await freshDoc();
+    await owner.applyUserActions(docId, [
+      ['AddTable', 'Table1', [{id: 'A'}]],
+      ['AddRecord', 'Table1', null, {}],
+      ['ModifyColumn', 'Table1', 'A', { formula: 'UUID()', isFormula: true }],
+      ['AddRecord', '_grist_ACLResources', -1, {tableId: '*', colIds: '*'}],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -1,
+        aclFormula: 'user.access != OWNER',
+        permissionsText: '-S',
+        memo: 'stop',
+      }],
+    ]);
+
+    const aMemBefore = await memCell('Table1', 'A', 1);
+    const aDbBefore = await dbCell('Table1', 'A', 1);
+    await assertDeniedFor(editor.applyUserActions(docId, [
+      ['RemoveColumn', 'Table1', 'A'],
+    ]), ['stop']);
+    const aMemAfter = await memCell('Table1', 'A', 1);
+    const aDbAfter = await dbCell('Table1', 'A', 1);
+    assert.equal(aMemAfter, aDbAfter);
+    assert.equal(aMemBefore, aDbBefore);
+    assert.notEqual(aDbBefore, aDbAfter);
+    assert.notEqual(aMemBefore, aMemAfter);
+  });
+
+  it('fails when action cannot be rejected', async () => {
+    // Reject schema actions
+    await freshDoc();
+    await owner.applyUserActions(docId, [
+      ['AddTable', 'Table1', [{id: 'A'}, {id: 'B'}]],
+      ['AddRecord', 'Table1', null, {B: 1}],
+      ['ModifyColumn', 'Table1', 'B', { isFormula: false, type: 'Int' }],
+      ['ModifyColumn', 'Table1', 'A', { formula: 'UUID() if $B else UUID()', isFormula: true }],
+      ['AddRecord', '_grist_ACLResources', -1, {tableId: 'Table1', colIds: '*'}],
+      ['AddRecord', '_grist_ACLRules', null, {
+        // We can't trigger A change as it will always have a different value.
+        // We can't also reject this action, as it will cause a fatal failure (with a direct action)
+        resource: -1,
+        aclFormula: 'newRec.A != rec.A',
+        permissionsText: '-U',
+        memo: 'doom',
+      }],
+    ]);
+    const engine = await docManager.getActiveDoc(docId)!;
+    // Now simulate a situation that extra actions generated by data engine are
+    // direct, with this, we should receive a fatal error.
+    const sharing = (engine as any)._sharing;
+    const stub: any = sinon.stub(sharing, '_createExtraBundle').callsFake((bundle: any, actions: any) => {
+      const result: SandboxActionBundle = stub.wrappedMethod(bundle, actions);
+      // Simulate direct actions.
+      result.direct = result.direct.map(([index]) => [index, true]);
+      return result;
+    });
+    try {
+      cliEditor.flush();
+      cliOwner.flush();
+      await assertFlux(editor.getDocAPI(docId).updateRows('Table1', {id: [1], B: [2]}));
+    } finally {
+      stub.restore();
+    }
+    assert.equal((await cliEditor.readMessage()).type, 'docShutdown');
+    assert.equal((await cliOwner.readMessage()).type, 'docShutdown');
+  });
+
+  async function memCell(tableId: string, colId: string, rowId: number) {
+    const engine = await docManager.getActiveDoc(docId)!;
+    const systemSession = makeExceptionalDocSession('system');
+    const {tableData} = await engine.fetchTable(systemSession, tableId, true);
+    return tableData[3][colId][tableData[2].indexOf(rowId)];
+  }
+
+  async function dbCell(tableId: string, colId: string, rowId: number) {
+    const engine = await docManager.getActiveDoc(docId)!;
+    const table = await engine.docStorage.fetchActionData(tableId, [rowId], [colId]);
+    return table[3][colId][0];
+  }
+
+  it('respects owner-private tables', async function() {
+    await freshDoc();
+
+    // Add spies to check whether unexpected calculations are made, to prevent
+    // regression of optimizations.
+    const granularAccess = await getGranularAccess();
+    const metaSteps = sinon.spy(granularAccess, '_getMetaSteps' as any);
+    const rowSteps = sinon.spy(granularAccess, '_getSteps' as any);
+    assert.equal(metaSteps.called, false);
+    assert.equal(rowSteps.called, false);
+
+    // Make a Private table and mark it as owner-only (using temporary representation).
+    // Make a Public table without any particular access control.
+    await owner.applyUserActions(docId, [
+      ['AddTable', 'Private', [{id: 'A'}]],
+      ['AddTable', 'PartialPrivate', [{id: 'A'}]],
+      ['AddRecord', 'PartialPrivate', null, { A: 0 }],
+      ['AddRecord', 'PartialPrivate', null, { A: 1 }],
+      ['AddRecord', '_grist_ACLResources', -1, {tableId: 'Private', colIds: '*'}],
+      ['AddRecord', '_grist_ACLResources', -2, {tableId: '*', colIds: '*'}],
+      ['AddRecord', '_grist_ACLResources', -3, {tableId: 'PartialPrivate', colIds: '*'}],
+      ['AddRecord', '_grist_ACLRules', null, {
+        // Negative IDs refer to rowIds used in the same action bundle.
+        resource: -1,
+        aclFormula: 'user.Access == "owners"',
+        permissionsText: 'all',
+        memo: 'owner check',
+      }],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -1, aclFormula: '', permissionsText: 'none',
+      }],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -2, aclFormula: 'user.Access != "owners"', permissionsText: '-S',  // drop schema rights
+      }],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -3, aclFormula: 'user.Access != "owners" and rec.A > 0', permissionsText: 'none',
+      }],
+      ['AddTable', 'Public', [{id: 'A'}]],
+    ]);
+
+    // Owner can access both Private and Public tables.
+    await assert.isFulfilled(owner.getDocAPI(docId).getRows('Private'));
+    await assert.isFulfilled(owner.getDocAPI(docId).getRows('Public'));
+
+    // Editor can access the Public table but not the Private table.
+    await assert.isRejected(editor.getDocAPI(docId).getRows('Private'));
+    await assert.isFulfilled(editor.getDocAPI(docId).getRows('Public'));
+
+    await assertDeniedFor(editor.getDocAPI(docId).getRows('Private'), ['owner check']);
+
+    // Metadata to editor should be filtered.  Private metadata gets blanked out
+    // rather than deleted, to keep ids consistent.
+    const tables = await editor.getDocAPI(docId).getRows('_grist_Tables');
+    assert.deepEqual(tables['tableId'], ['Table1', '', 'PartialPrivate', 'Public']);
+
+    // Owner can download, editor can not.
+    await assert.isFulfilled((await owner.getWorkerAPI(docId)).downloadDoc(docId));
+    await assert.isRejected((await editor.getWorkerAPI(docId)).downloadDoc(docId));
+
+    // Owner can copy, editor can not.
+    await assert.isFulfilled((await owner.getWorkerAPI(docId)).copyDoc(docId));
+    await assert.isRejected((await editor.getWorkerAPI(docId)).copyDoc(docId));
+
+    // Owner can use AddColumn, editor can not (even for public table).
+    await assert.isFulfilled(owner.applyUserActions(docId, [
+      ['AddColumn', 'Public', 'B', {}],
+      ['AddColumn', 'Public', 'C', {}],
+    ]));
+    await assert.isRejected(editor.applyUserActions(docId, [
+      ['AddColumn', 'Public', 'editorB', {}]
+    ]));
+
+    // Owner can use RemoveColumn, editor can not (even for public table).
+    await assert.isFulfilled(owner.applyUserActions(docId, [
+      ['RemoveColumn', 'Public', 'B']
+    ]));
+    await assert.isRejected(editor.applyUserActions(docId, [
+      ['RemoveColumn', 'Public', 'C']
+    ]));
+
+    // Check that changing a private table's data results in a broadcast to owner but not editor.
+    cliEditor.flush();
+    cliOwner.flush();
+    await owner.getDocAPI(docId).addRows('Private', {A: [99, 100]});
+    assert.lengthOf(await cliOwner.readDocUserAction(), 1);
+    assert.equal(cliEditor.count(), 0);
+
+    // Check that changing a private table's columns results in a full broadcast to owner, but
+    // a filtered broadcast to editor.
+    await assert.isFulfilled(owner.applyUserActions(docId, [
+      ['AddVisibleColumn', 'Private', 'X', {}],
+    ]));
+    const ownerUpdate = await cliOwner.readDocUserAction();
+    const editorUpdate = await cliEditor.readDocUserAction();
+    assert.deepEqual(ownerUpdate.map(a => a[0]), ['AddColumn', 'AddRecord', 'AddRecord', 'AddRecord', 'AddRecord']);
+    assert.deepEqual(editorUpdate.map(a => a[0]), ['AddRecord', 'AddRecord', 'AddRecord', 'AddRecord']);
+    assert.equal((ownerUpdate[1] as AddRecord)[3].label, 'X');
+    assert.equal((editorUpdate[0] as AddRecord)[3].label, '');
+
+    // Owner can modify metadata, editor can not.
+    await assert.isFulfilled(owner.applyUserActions(docId, [
+      ["UpdateRecord", "_grist_Tables_column", 1, {formula: "X"}]
+    ]));
+    await assert.isRejected(editor.applyUserActions(docId, [
+      ["UpdateRecord", "_grist_Tables_column", 1, {formula: "Y"}]
+    ]));
+    await assert.isFulfilled(owner.applyUserActions(docId, [
+      ["AddRecord", "_grist_Tables_column", null, {formula: ""}]
+    ]));
+    await assert.isRejected(editor.applyUserActions(docId, [
+      ["AddRecord", "_grist_Tables_column", null, {formula: ""}]
+    ]));
+
+    // Check we have never computed row steps yet.
+    assert.equal(metaSteps.called, true);
+    assert.equal(rowSteps.called, false);
+
+    // Now do something to tickle row step calculation, and make sure it happens.
+    await owner.getDocAPI(docId).addRows('PartialPrivate', {A: [99, 100]});
+    assert.equal(rowSteps.called, true);
+
+    // Check editor cannot see private table schema via fetchTableSchema.
+    assert.match((await cliEditor.send('fetchTableSchema', 0)).error!, /Cannot view code/);
+    assert.equal((await cliOwner.send('fetchTableSchema', 0)).error, undefined);
+  });
+
+  it('reports memos sensibly', async function() {
+    await freshDoc();
+    await owner.applyUserActions(docId, [
+      ['AddTable', 'Table1', [{id: 'A'}]],
+      ['AddRecord', 'Table1', null, {A: 'test1'}],
+      ['AddRecord', 'Table1', null, {A: 'test2'}],
+      ['AddTable', 'Table2', [{id: 'A'}]],
+      ['AddRecord', '_grist_ACLResources', -1, {tableId: '*', colIds: '*'}],
+      ['AddRecord', '_grist_ACLResources', -2, {tableId: 'Table2', colIds: '*'}],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -1, aclFormula: 'rec.A == "test1"', permissionsText: 'none',
+      }],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -1,
+        aclFormula: 'rec.A == "test2"',
+        permissionsText: '-D',
+        memo: 'rule_d1',
+      }],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -1,
+        aclFormula: 'rec.A == "test2"',
+        permissionsText: '-D',
+        memo: 'rule_d2',
+      }],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -1,
+        aclFormula: 'rec.A == "test1"',
+        permissionsText: '+U',
+        memo: 'rule_u',
+      }],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -1,   // Used to have -2, but table-specific rules cannot specify schemaEdit
+                        // permission today; it now gets ignored if they do.
+        aclFormula: 'True',
+        permissionsText: '-S',
+        memo: 'rule_s',
+      }],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -1, aclFormula: '', permissionsText: '-U',
+      }],
+    ]);
+    await assertDeniedFor(owner.getDocAPI(docId).removeRows('Table1', [1]), []);
+    await assertDeniedFor(owner.getDocAPI(docId).removeRows('Table1', [2]), ['rule_d1', 'rule_d2']);
+    await assertDeniedFor(owner.getDocAPI(docId).updateRows('Table1', {id: [2], A: ['x']}),
+                          ['rule_u']);
+    await assertDeniedFor(owner.applyUserActions(docId, [
+      ['AddVisibleColumn', 'Table2', 'B', {}],
+    ]), ['rule_s']);
+    await assertDeniedFor(owner.applyUserActions(docId, [
+      ['ModifyColumn', 'Table2', 'A', {formula: 'a formula'}],
+    ]), ['rule_s']);
+  });
+
+  it('respects table wildcard', async function() {
+    await freshDoc();
+
+    // Make a Private table, using wildcard.
+    await owner.applyUserActions(docId, [
+      ['AddTable', 'Private', [{id: 'A'}]],
+      ['AddRecord', '_grist_ACLResources', -1, {tableId: '*', colIds: '*'}],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -1, aclFormula: 'user.Access != "owners"', permissionsText: 'none',
+      }],
+
+      ['AddTable', 'Private', [{id: 'A'}]],
+    ]);
+
+    // Owner can access Private table.
+    await assert.isFulfilled(owner.getDocAPI(docId).getRows('Private'));
+
+    // Editor cannot access Private table.
+    await assert.isRejected(editor.getDocAPI(docId).getRows('Private'));
+  });
+
+  it('checks for special actions after schema actions', async function() {
+    await freshDoc();
+
+    // Make a table with an owner-private column, and with only the owner
+    // allowed to make schema changes.
+    await owner.applyUserActions(docId, [
+      ['AddTable', 'Data1', [{id: 'A'}, {id: 'B', widgetOptions: "{}"}]],
+      ['AddRecord', 'Data1', null, {A: 'a1', B: 'b1'}],
+      ['AddRecord', 'Data1', null, {A: 'a2', B: 'b2'}],
+      ['AddRecord', '_grist_ACLResources', -1, {tableId: 'Data1', colIds: 'A'}],
+      ['AddRecord', '_grist_ACLResources', -2, {tableId: '*', colIds: '*'}],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -1, aclFormula: 'user.Access not in [OWNER]', permissionsText: '-RU',
+      }],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -2, aclFormula: 'user.Access not in [OWNER]', permissionsText: '-S',  // drop schema rights
+      }],
+    ]);
+
+    assert.deepEqual(await owner.getDocAPI(docId).getRows('Data1'), {
+      id: [ 1, 2 ],
+      manualSort: [ 1, 2 ],
+      A: [ 'a1', 'a2' ],
+      B: [ 'b1', 'b2' ],
+    });
+
+    assert.deepEqual(await editor.getDocAPI(docId).getRows('Data1'), {
+      id: [ 1, 2 ],
+      manualSort: [ 1, 2 ],
+      B: [ 'b1', 'b2' ],
+    });
+
+    await assert.isRejected(editor.applyUserActions(docId, [
+      ['CopyFromColumn', 'Data1', 'A', 'B', {}],
+    ]), /need uncomplicated access/);
+
+    await assert.isRejected(editor.applyUserActions(docId, [
+      ['RenameColumn', 'Data1', 'B', 'B'],
+      ['CopyFromColumn', 'Data1', 'A', 'B', {}],
+    ]), /need uncomplicated access/);
+
+    assert.deepEqual(await editor.getDocAPI(docId).getRows('Data1'), {
+      id: [ 1, 2 ],
+      manualSort: [ 1, 2 ],
+      B: [ 'b1', 'b2' ],
+    });
+
+    await assert.isFulfilled(owner.applyUserActions(docId, [
+      ['RenameColumn', 'Data1', 'B', 'B'],
+      ['CopyFromColumn', 'Data1', 'A', 'B', {}],
+    ]));
+
+    assert.deepEqual(await editor.getDocAPI(docId).getRows('Data1'), {
+      id: [ 1, 2 ],
+      manualSort: [ 1, 2 ],
+      B: [ 'a1', 'a2' ],
+    });
+  });
+
+  it('respects owner-only structure', async function() {
+    await freshDoc();
+
+    // Make some tables, and lock structure.
+    await owner.applyUserActions(docId, [
+      ['AddTable', 'Public1', [{id: 'A', type: 'Text'}]],
+      ['AddRecord', '_grist_ACLResources', -1, {tableId: '*', colIds: '*'}],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -1, aclFormula: 'user.Access != "owners"', permissionsText: '-S',
+      }],
+      ['AddTable', 'Public2', [{id: 'A', type: 'Text'}]],
+    ]);
+
+    // Owner can access all tables.
+    await assert.isFulfilled(owner.getDocAPI(docId).getRows('Public1'));
+    await assert.isFulfilled(owner.getDocAPI(docId).getRows('Public2'));
+
+    // Editor can access all tables.
+    await assert.isFulfilled(editor.getDocAPI(docId).getRows('Public1'));
+    await assert.isFulfilled(editor.getDocAPI(docId).getRows('Public2'));
+
+    // Owner and editor can download.
+    await assert.isFulfilled((await owner.getWorkerAPI(docId)).downloadDoc(docId));
+    await assert.isFulfilled((await editor.getWorkerAPI(docId)).downloadDoc(docId));
+
+    // Owner and editor can download.
+    await assert.isFulfilled((await owner.getWorkerAPI(docId)).copyDoc(docId));
+    await assert.isFulfilled((await editor.getWorkerAPI(docId)).copyDoc(docId));
+
+    // Owner can use AddColumn, editor can not.
+    await assert.isFulfilled(owner.applyUserActions(docId, [
+      ['AddVisibleColumn', 'Public1', 'B', {}],
+      ['AddColumn', 'Public1', 'C', {}],
+    ]));
+    await assert.isRejected(editor.applyUserActions(docId, [
+      ['AddVisibleColumn', 'Public1', 'editorB', {}]
+    ]));
+    await assert.isRejected(editor.applyUserActions(docId, [
+      ['AddColumn', 'Public1', 'editorB', {}]
+    ]));
+
+    // Owner can use RemoveColumn, editor can not.
+    await assert.isFulfilled(owner.applyUserActions(docId, [
+      ['RemoveColumn', 'Public1', 'B']
+    ]));
+    await assert.isRejected(editor.applyUserActions(docId, [
+      ['RemoveColumn', 'Public1', 'C']
+    ]));
+
+    // Owner can add an empty table, editor can not.
+    await assert.isFulfilled(owner.applyUserActions(docId, [
+      ["AddEmptyTable", null]
+    ]));
+    await assert.isRejected(editor.applyUserActions(docId, [
+      ["AddEmptyTable", null]
+    ]), /Blocked by table structure access rules/);
+
+    // Owner can duplicate a table, editor can not.
+    await assert.isFulfilled(owner.applyUserActions(docId, [
+      ['DuplicateTable', 'Public1', 'Public1Copy', false]
+    ]));
+    await assert.isRejected(editor.applyUserActions(docId, [
+      ['DuplicateTable', 'Public1', 'Public1Copy', false]
+    ]), /Blocked by table structure access rules/);
+
+    // Owner can modify metadata, editor can not.
+    await assert.isFulfilled(owner.applyUserActions(docId, [
+      ["UpdateRecord", "_grist_Tables_column", 1, {formula: ""}]
+    ]));
+    await assert.isRejected(editor.applyUserActions(docId, [
+      ["UpdateRecord", "_grist_Tables_column", 1, {formula: "X"}]
+      // Need to change formula, or update will be ignored and thus succeed
+    ]));
+    await assert.isFulfilled(owner.applyUserActions(docId, [
+      ["AddRecord", "_grist_Tables_column", null, {formula: ""}]
+    ]));
+    await assert.isRejected(editor.applyUserActions(docId, [
+      ["AddRecord", "_grist_Tables_column", null, {formula: ""}]
+    ]));
+    await assert.isFulfilled(owner.applyUserActions(docId, [
+      ["UpdateRecord", "_grist_Pages", 1, {indentation: 2}]
+    ]));
+    await assert.isRejected(editor.applyUserActions(docId, [
+      ["UpdateRecord", "_grist_Pages", 1, {indentation: 3}]
+    ]));
+  });
+
+  it('owner can edit rules without structure permission', async function() {
+    await freshDoc();
+
+    // Make some tables, and lock structure completely.
+    await owner.applyUserActions(docId, [
+      ['AddTable', 'Public1', [{id: 'A'}]],
+      ['AddRecord', '_grist_ACLResources', -1, {tableId: '*', colIds: '*'}],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -1, aclFormula: '', permissionsText: '-S',
+      }],
+      ['AddTable', 'Public2', [{id: 'A'}]],
+    ]);
+
+    // Can still read.
+    await assert.isFulfilled(owner.getDocAPI(docId).getRows('Public1'));
+
+    // Can edit data.
+    await assert.isFulfilled(owner.getDocAPI(docId).addRows('Public1', {A: [67]}));
+
+    // Cannot rename column.
+    await assert.isRejected(owner.applyUserActions(docId, [
+      ['RenameColumn', 'Public1', 'A', 'Z'],
+    ]), /Blocked by table structure access rules/);
+
+    // Can still change rules.
+    await owner.applyUserActions(docId, [
+      ['AddRecord', '_grist_ACLResources', -1, {tableId: '*', colIds: '*'}],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -1, aclFormula: 'True', permissionsText: '+S',
+      }],
+    ]);
+
+    // Can change columns again.
+    await assert.isFulfilled(owner.applyUserActions(docId, [
+      ['RenameColumn', 'Public1', 'A', 'Z'],
+    ]));
+  });
+
+  it("supports AddEmptyTable", async function() {
+    await freshDoc();
+    // Make some tables, and lock structure.
+    await owner.applyUserActions(docId, [
+      ['AddTable', 'Public1', [{id: 'A'}]],
+      ['AddRecord', '_grist_ACLResources', -1, {tableId: '*', colIds: '*'}],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -1, aclFormula: 'user.Access != "owners"', permissionsText: '-S',
+      }],
+      ['AddTable', 'Public2', [{id: 'A'}]],
+    ]);
+
+    await assert.isFulfilled(owner.applyUserActions(docId, [
+      ["AddEmptyTable", null]
+    ]));
+    await assert.isRejected(editor.applyUserActions(docId, [
+      ["AddEmptyTable", null]
+    ]));
+  });
+
+  it("blocks formulas early", async function() {
+    await freshDoc();
+    // Make some tables, and lock structure.
+    await owner.applyUserActions(docId, [
+      ['AddTable', 'Table1', [{id: 'A'}]],
+      ['AddRecord', 'Table1', null, {A: [100]}],
+      ['AddRecord', '_grist_ACLResources', -1, {tableId: '*', colIds: '*'}],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -1, aclFormula: 'user.Access != "owners"', permissionsText: '-S',
+      }],
+    ]);
+
+    // Try a modification that would have a detectable side-effect even if reverted.
+    await assert.isRejected(editor.applyUserActions(docId, [
+      ["ModifyColumn", "Table1", "A", {"isFormula": true, formula: "datetime.MAXYEAR=1234",
+                                       type: 'Int'}]
+    ]), /Blocked by full structure access rules/);
+
+    await assert.isRejected(editor.applyUserActions(docId, [
+      ["UpdateRecord", "_grist_Tables_column", 1, {formula: "datetime.MAXYEAR=1234"}]
+    ]), /Blocked by full structure access rules/);
+
+    await assert.isRejected(editor.applyUserActions(docId, [
+      ["AddRecord", "_grist_Tables_column", null, {formula: "datetime.MAXYEAR=1234"}]
+    ]), /Blocked by full structure access rules/);
+
+    await assert.isRejected(editor.applyUserActions(docId, [
+      ["AddRecord", "_grist_Validations", null, {formula: "datetime.MAXYEAR=1234"}]
+    ]), /Blocked by full structure access rules/);
+
+    await assert.isRejected(editor.applyUserActions(docId, [
+      ["SetDisplayFormula", "Table1", null, 1, "datetime.MAXYEAR=1234"]
+    ]), /Blocked by full structure access rules/);
+
+    // Make sure that the poison formula was never evaluated.
+    await owner.applyUserActions(docId, [
+      ["ModifyColumn", "Table1", "A", {"isFormula": true, formula: "datetime.MAXYEAR",
+                                       type: 'Int'}]
+    ]);
+    assert.deepEqual((await owner.getDocAPI(docId).getRows('Table1')).A, [9999]);
+  });
+
+  it("allows AddOrUpdateRecord only with full read access", async function() {
+    await freshDoc();
+    // Make some tables, and lock structure.
+    await owner.applyUserActions(docId, [
+      ['AddTable', 'Data1', [{id: 'A', type: 'Numeric'}]],
+      ['AddRecord', 'Data1', null, {A: 100}],
+      ['AddTable', 'Data2', [{id: 'A', type: 'Numeric'}]],
+      ['AddRecord', 'Data2', null, {A: 100}],
+      ['AddTable', 'Data3', [{id: 'A', type: 'Numeric'}]],
+      ['AddRecord', 'Data3', null, {A: 100}],
+      ['AddTable', 'Data4', [{id: 'A', type: 'Numeric'}]],
+      ['AddRecord', 'Data4', null, {A: 100}],
+      ['AddTable', 'Data5', [{id: 'A', type: 'Numeric'}]],
+      ['AddRecord', 'Data5', null, {A: 100}],
+      ['AddRecord', '_grist_ACLResources', -1, {tableId: 'Data2', colIds: '*'}],
+      ['AddRecord', '_grist_ACLResources', -2, {tableId: 'Data3', colIds: '*'}],
+      ['AddRecord', '_grist_ACLResources', -3, {tableId: 'Data4', colIds: '*'}],
+      ['AddRecord', '_grist_ACLResources', -4, {tableId: 'Data5', colIds: '*'}],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -1, aclFormula: 'user.Access != "owners"', permissionsText: '-R',
+      }],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -2, aclFormula: 'rec.A == 999', permissionsText: '-R',
+      }],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -3, aclFormula: 'user.Access != "owners"', permissionsText: '-U',
+      }],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -4, aclFormula: 'user.Access != "owners"', permissionsText: '-C',
+      }],
+    ]);
+
+    // Can AddOrUpdateRecord on a table with full read access.
+    await assert.isFulfilled(editor.applyUserActions(docId, [
+      ["AddOrUpdateRecord", "Data1", {"A": 100}, {"A": 200}, {}]
+    ]));
+    assert.deepEqual(await editor.getDocAPI(docId).getRows('Data1'), {
+      id: [ 1 ],
+      manualSort: [ 1 ],
+      A: [ 200 ],
+    });
+
+    // Cannot AddOrUpdateRecord on a table without read access.
+    await assert.isRejected(editor.applyUserActions(docId, [
+      ["AddOrUpdateRecord", "Data2", {"A": 100}, {"A": 200}, {}]
+    ]), /Blocked by table read access rules/);
+
+    // Cannot AddOrUpdateRecord on a table with partial read access.
+    await assert.isRejected(editor.applyUserActions(docId, [
+      ["AddOrUpdateRecord", "Data3", {"A": 100}, {"A": 200}, {}]
+    ]), /Blocked by table read access rules/);
+
+    // Currently cannot combine AddOrUpdateRecord with RenameTable.
+    await assert.isRejected(editor.applyUserActions(docId, [
+      ["RenameTable", "Data1", "DataX"],
+      ["RenameTable", "Data2", "Data1"],
+      ["AddOrUpdateRecord", "Data1", {"A": 200}, {"A": 300}, {}]
+    ]), /Can only combine AddOrUpdateRecord and BulkAddOrUpdateRecord with simple data changes/);
+
+    // Currently cannot use AddOrUpdateRecord for metadata changes.
+    await assert.isRejected(editor.applyUserActions(docId, [
+      ["AddOrUpdateRecord", "Data1", {"A": 200}, {"A": 300}, {}],
+      ["AddOrUpdateRecord", "_grist_Tables", {tableId: "Data1"}, {tableId: "DataX"}, {}],
+    ]), /AddOrUpdateRecord cannot yet be used on metadata tables/);
+
+    // Currently cannot combine AddOrUpdateRecord with metadata changes.
+    await assert.isRejected(editor.applyUserActions(docId, [
+      ["AddOrUpdateRecord", "Data1", {"A": 200}, {"A": 300}, {}],
+      ["UpdateRecord", "_grist_Tables", 1, {tableId: "DataX"}],
+    ]), /Can only combine AddOrUpdateRecord and BulkAddOrUpdateRecord with simple data changes/);
+
+    // Can combine some simple data changes.
+    await assert.isFulfilled(editor.applyUserActions(docId, [
+      ["AddOrUpdateRecord", "Data1", {"A": 200}, {"A": 300}, {}],
+      ["AddOrUpdateRecord", "Data1", {"A": 500}, {"A": 600}, {}],
+      ["AddOrUpdateRecord", "Data1", {"A": 300}, {"A": 400}, {}],
+    ]));
+    assert.deepEqual(await editor.getDocAPI(docId).getRows('Data1'), {
+      id: [ 1, 2 ],
+      manualSort: [ 1, 2 ],
+      A: [ 400, 600 ],
+    });
+
+    // Need both update + create rights
+    await assert.isRejected(editor.applyUserActions(docId, [
+      ["AddOrUpdateRecord", "Data4", {"A": 100}, {"A": 200}, {}],
+    ]), /Blocked by table update access rules/);
+    await assert.isRejected(editor.applyUserActions(docId, [
+      ["AddOrUpdateRecord", "Data4", {"A": 300}, {"A": 200}, {}],
+    ]), /Blocked by table update access rules/);
+    await assert.isRejected(editor.applyUserActions(docId, [
+      ["AddOrUpdateRecord", "Data5", {"A": 100}, {"A": 200}, {}],
+    ]), /Blocked by table create access rules/);
+    await assert.isRejected(editor.applyUserActions(docId, [
+      ["AddOrUpdateRecord", "Data5", {"A": 300}, {"A": 200}, {}],
+    ]), /Blocked by table create access rules/);
+  });
+
+  it("allows DuplicateTable only with full read access", async function() {
+    await freshDoc();
+    await owner.applyUserActions(docId, [
+      ['AddTable', 'Data1', [{id: 'A', type: 'Numeric'}]],
+      ['AddRecord', 'Data1', null, {A: 100}],
+      ['AddTable', 'Data2', [{id: 'A', type: 'Numeric'}]],
+      ['AddRecord', 'Data2', null, {A: 100}],
+      ['AddTable', 'Data3', [{id: 'A', type: 'Numeric'}]],
+      ['AddRecord', 'Data3', null, {A: 100}],
+      ['AddTable', 'Data4', [{id: 'A', type: 'Numeric'}]],
+      ['AddRecord', 'Data4', null, {A: 100}],
+      ['AddTable', 'Data5', [{id: 'A', type: 'Numeric'}]],
+      ['AddRecord', 'Data5', null, {A: 100}],
+      ['AddRecord', '_grist_ACLResources', -1, {tableId: 'Data2', colIds: '*'}],
+      ['AddRecord', '_grist_ACLResources', -2, {tableId: 'Data3', colIds: '*'}],
+      ['AddRecord', '_grist_ACLResources', -3, {tableId: 'Data4', colIds: '*'}],
+      ['AddRecord', '_grist_ACLResources', -4, {tableId: 'Data5', colIds: '*'}],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -1, aclFormula: 'user.Access != "owners"', permissionsText: '-R',
+      }],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -2, aclFormula: 'rec.A == 999', permissionsText: '-R',
+      }],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -4, aclFormula: 'user.Access != "owners"', permissionsText: '-C',
+      }],
+    ]);
+
+    // Can perform DuplicateTable on a table with full read access.
+    await assert.isFulfilled(editor.applyUserActions(docId, [
+      ["DuplicateTable", "Data1", "Data1Copy", true]
+    ]));
+    assert.deepEqual(await editor.getDocAPI(docId).getRows('Data1Copy'), {
+      id: [ 1 ],
+      manualSort: [ 1 ],
+      A: [ 100 ],
+    });
+
+    // Cannot perform DuplicateTable on a table without read access.
+    for (const includeData of [false, true]) {
+      await assert.isRejected(editor.applyUserActions(docId, [
+        ["DuplicateTable", "Data2", "Data2Copy", includeData]
+      ]), /Blocked by table read access rules/);
+    }
+
+    // Cannot perform DuplicateTable on a table with partial read access.
+    for (const includeData of [false, true]) {
+      await assert.isRejected(editor.applyUserActions(docId, [
+        ["DuplicateTable", "Data3", "Data3Copy", includeData]
+      ]), /Blocked by table read access rules/);
+    }
+
+    // Cannot perform DuplicateTable (with data) on a table without create access.
+    await assert.isRejected(editor.applyUserActions(docId, [
+      ["DuplicateTable", "Data5", "Data5Copy", true]
+    ]), /Blocked by table create access rules/);
+
+    // Check that denied schemaEdit prevents duplication. We can duplicate Data4 table until we deny schemaEdit.
+    await assert.isFulfilled(editor.applyUserActions(docId, [
+      ["DuplicateTable", "Data1", "Data4Copy0", true]
+    ]));
+    await owner.applyUserActions(docId, [
+      ['AddRecord', '_grist_ACLResources', -1, {tableId: '*', colIds: '*'}],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -1, aclFormula: 'user.Access != "owners"', permissionsText: '-S',
+      }],
+    ]);
+    // Cannot perform DuplicateTable on a table without schema edit access.
+    for (const includeData of [false, true]) {
+      await assert.isRejected(editor.applyUserActions(docId, [
+        ["DuplicateTable", "Data4", "Data4Copy", includeData]
+      ]), /Blocked by table structure access rules/);
+    }
+
+    // Owner can still perform DuplicateTable, even with partial read access or
+    // without schema edit access.
+    for (const includeData of [false, true]) {
+      await assert.isFulfilled(owner.applyUserActions(docId, [
+        ["DuplicateTable", "Data3", "Data3Copy", includeData]
+      ]));
+      await assert.isFulfilled(owner.applyUserActions(docId, [
+        ["DuplicateTable", "Data4", "Data4Copy", includeData]
+      ]));
+    }
+
+    // Cannot combine DuplicateTable with other actions.
+    for (const includeData of [false, true]) {
+      await assert.isRejected(owner.applyUserActions(docId, [
+        ["UpdateRecord", "_grist_Tables", 4, {tableId: "Data3New"}],
+        ["DuplicateTable", "Data3New", "Data3NewCopy", includeData],
+      ]), /DuplicateTable currently cannot be combined with other actions/);
+      await assert.isRejected(owner.applyUserActions(docId, [
+        ["AddOrUpdateRecord", "Data3", {"A": 100}, {"A": 200}, {}],
+        ["DuplicateTable", "Data3", "Data3Copy", includeData],
+      ]), /DuplicateTable currently cannot be combined with other actions/);
+      await assert.isRejected(owner.applyUserActions(docId, [
+        ["DuplicateTable", "Data3", "Data3Copy", includeData],
+        ["AddRecord", "Data3Copy", null, {"A": 100}],
+      ]), /DuplicateTable currently cannot be combined with other actions/);
+    }
+
+    // Cannot duplicate metadata tables.
+    for (const includeData of [false, true]) {
+      await assert.isRejected(owner.applyUserActions(docId, [
+        ["DuplicateTable", "_grist_Tables", "_grist_Tables", includeData],
+      ]), /DuplicateTable cannot be used on metadata tables/);
+    }
+  });
+
+  it('allows a table that only owner can add/remove rows from', async function() {
+    await freshDoc();
+
+    await owner.applyUserActions(docId, [
+      ['AddTable', 'Data', [{id: 'A'}]],
+      ['AddRecord', 'Data', null, {A: 42}],
+      ['AddRecord', '_grist_ACLResources', -1, {tableId: '*', colIds: '*'}],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -1, aclFormula: 'user.Access != "owners"', permissionsText: '-CD',
+      }]
+    ]);
+
+    // Owner and editor can read table.
+    assert.lengthOf((await owner.getDocAPI(docId).getRows('Data')).id, 1);
+    assert.lengthOf((await editor.getDocAPI(docId).getRows('Data')).id, 1);
+
+    // Owner and editor can modify rows.
+    await assert.isFulfilled(owner.getDocAPI(docId).updateRows('Data', {id: [1], A: [67]}));
+    await assert.isFulfilled(editor.getDocAPI(docId).updateRows('Data', {id: [1], A: [68]}));
+
+    // Editor cannot add or remove rows.
+    await assert.isRejected(editor.getDocAPI(docId).addRows('Data', {A: [999]}));
+    await assert.isRejected(editor.getDocAPI(docId).removeRows('Data', [1]));
+
+    // Owner can add and remove rows.
+    await assert.isFulfilled(owner.getDocAPI(docId).addRows('Data', {A: [999]}));
+    await assert.isFulfilled(owner.getDocAPI(docId).removeRows('Data', [1]));
+  });
+
+  it('respects row-level access control', async function() {
+    await freshDoc();
+    // Make a table, and limit non-owner access to some rows.
+    await owner.applyUserActions(docId, [
+      ['AddTable', 'Data1', [{id: 'A'},
+                            {id: 'B'},
+                            {id: 'Public', isFormula: true, formula: '$B == "clear"'}]],
+      ['AddRecord', 'Data1', null, {A: 1, B: 'clear'}],
+      ['AddRecord', 'Data1', null, {A: 2, B: 'notclear'}],
+      ['AddRecord', 'Data1', null, {A: 3, B: 'clear'}],
+
+      ['AddRecord', '_grist_ACLResources', -1, {tableId: 'Data1', colIds: '*'}],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -1, aclFormula: 'user.Access != "owners" and not rec.Public', permissionsText: 'none',
+      }],
+      // This alternative is equivalent:
+      //    aclFormula: 'user.Access == "owners" or rec.Public', permissionsText: 'all',
+      //    aclFormula: '', permissionsText: 'none',
+      ['AddTable', 'Data2', [{id: 'A'}, {id: 'B'}]],
+      ['AddRecord', 'Data2', null, {A: 1, B: 2}],
+    ]);
+    assert.deepEqual((await owner.getDocAPI(docId).getRows('Data1')).id, [1, 2, 3]);
+    assert.deepEqual((await editor.getDocAPI(docId).getRows('Data1')).id, [1, 3]);
+
+    // Owner can edit all rows, "editor" can only edit public rows.
+    await assert.isFulfilled(owner.getDocAPI(docId).updateRows(
+      'Data1', { id: [1], A: [99] }));
+    await assert.isFulfilled(editor.getDocAPI(docId).updateRows(
+      'Data1', { id: [1], A: [99] }));
+    await assert.isRejected(editor.getDocAPI(docId).updateRows(
+      'Data1', { id: [2], A: [99] }));
+
+    // For other tables, editor has normal rights on rows.
+    await assert.isFulfilled(owner.getDocAPI(docId).updateRows(
+      'Data2', { id: [1], A: [99] }));
+    await assert.isFulfilled(editor.getDocAPI(docId).updateRows(
+      'Data2', { id: [1], A: [99] }));
+  });
+
+  it('respects row-level access control on updates', async function() {
+    await freshDoc();
+    // Make a table, and allow update of rows matching a condition.
+    await owner.applyUserActions(docId, [
+      ['AddTable', 'Data1', [{id: 'A', type: 'Numeric'},
+                             {id: 'B', type: 'Numeric'}]],
+      ['AddRecord', 'Data1', null, {A: 1, B: 100}],
+      ['AddRecord', 'Data1', null, {A: 2, B: 200}],
+      ['AddRecord', 'Data1', null, {A: 3, B: 300}],
+
+      ['AddRecord', '_grist_ACLResources', -1, {tableId: 'Data1', colIds: '*'}],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -1, aclFormula: 'user.Access != "owners" and newRec.B <= rec.B', permissionsText: '-U',
+      }],
+    ]);
+    await assert.isFulfilled(editor.getDocAPI(docId).updateRows(
+      'Data1', { id: [1], B: [101] }));
+    await assert.isRejected(editor.getDocAPI(docId).updateRows(
+      'Data1', { id: [1], B: [99] }));
+    await assert.isFulfilled(owner.getDocAPI(docId).updateRows(
+      'Data1', { id: [1], B: [98] }));
+    await assert.isFulfilled(editor.getDocAPI(docId).updateRows(
+      'Data1', { id: [1], B: [99] }));
+  });
+
+  it('handles schema changes within a bundle', async function() {
+    await freshDoc();
+    // Owner limits their own row access to a certain table.
+    await owner.applyUserActions(docId, [
+      ['AddTable', 'Data1', [{id: 'A', type: 'Numeric'},
+                             {id: 'B', type: 'Numeric'}]],
+      ['AddRecord', 'Data1', null, {A: 1, B: 100}],
+      ['AddRecord', 'Data1', null, {A: 2, B: 200}],
+      ['AddRecord', 'Data1', null, {A: 3, B: 100}],
+      ['AddTable', 'Data2', [{id: 'A', type: 'Numeric'},
+                             {id: 'B', type: 'Numeric'}]],
+      ['AddRecord', 'Data2', null, {A: 1, B: 100}],
+      ['AddRecord', 'Data2', null, {A: 2, B: 200}],
+      ['AddRecord', 'Data2', null, {A: 3, B: 100}],
+      ['AddRecord', '_grist_ACLResources', -1, {tableId: 'Data1', colIds: '*'}],
+      ['AddRecord', '_grist_ACLResources', -2, {tableId: 'Data2', colIds: '*'}],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -1, aclFormula: 'user.Access != "owners"', permissionsText: '-U',
+      }],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -2, aclFormula: 'rec.B == 100', permissionsText: '-U',
+      }],
+    ]);
+    await assert.isRejected(editor.applyUserActions(docId, [
+      ['UpdateRecord', 'Data2', 3, {A: 99}],
+    ]));
+    await assert.isFulfilled(editor.applyUserActions(docId, [
+      ['UpdateRecord', 'Data2', 2, {A: 99}],
+    ]));
+    await assert.isRejected(editor.applyUserActions(docId, [
+      ['UpdateRecord', 'Data1', 2, {A: 99}],
+    ]));
+    // Swap Data1 and Data2 names, and check all is well.
+    await assert.isFulfilled(editor.applyUserActions(docId, [
+      ['RenameTable', 'Data1', 'Data3'],
+      ['RenameTable', 'Data2', 'Data1'],
+      ['RenameTable', 'Data3', 'Data2'],
+      ['UpdateRecord', 'Data1', 2, {A: 99}],
+    ]));
+    await assert.isRejected(editor.applyUserActions(docId, [
+      ['UpdateRecord', 'Data1', 3, {A: 99}],
+    ]));
+    await assert.isFulfilled(editor.applyUserActions(docId, [
+      ['UpdateRecord', 'Data1', 2, {A: 99}],
+    ]));
+    await assert.isRejected(editor.applyUserActions(docId, [
+      ['UpdateRecord', 'Data2', 2, {A: 99}],
+    ]));
+
+    // This swaps A and B for Data1 (originally Data2).
+    await assert.isFulfilled(editor.applyUserActions(docId, [
+      ['RenameColumn', 'Data1', 'A', 'C'],
+      ['RenameColumn', 'Data1', 'B', 'A'],
+      ['RenameColumn', 'Data1', 'C', 'B'],
+      ['UpdateRecord', 'Data1', 2, {B: 99}],
+    ]));
+    await assert.isRejected(editor.applyUserActions(docId, [
+      ['UpdateRecord', 'Data1', 3, {B: 99}],
+    ]));
+    await assert.isFulfilled(editor.applyUserActions(docId, [
+      ['UpdateRecord', 'Data1', 2, {B: 99}],
+    ]));
+    await assert.isRejected(editor.applyUserActions(docId, [
+      ['RenameColumn', 'Data1', 'A', 'C'],
+      ['RenameColumn', 'Data1', 'B', 'A'],
+      ['RenameColumn', 'Data1', 'C', 'B'],
+      ['UpdateRecord', 'Data1', 3, {A: 99}],
+    ]));
+  });
+
+  it('only owners can change rules', async function() {
+    // We currently have hardcoded permission that only owners can edit rules.
+    await freshDoc();
+    await owner.applyUserActions(docId, [
+      ['AddTable', 'Data1', [{id: 'A', type: 'Numeric'},
+                             {id: 'B', type: 'Numeric'}]],
+      ['AddTable', 'Sensitive', [{id: 'A', type: 'Numeric'},
+                                 {id: 'B', type: 'Numeric'}]],
+      ['AddRecord', '_grist_ACLResources', -1, {tableId: 'Data1', colIds: '*'}],
+      ['AddRecord', '_grist_ACLResources', -2, {tableId: 'Sensitive', colIds: '*'}],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -1, aclFormula: 'newRec.A != 1', permissionsText: '-U',
+      }],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -2, aclFormula: 'user.Access != "owners"', permissionsText: '-R',
+      }],
+    ]);
+    cliEditor.flush();
+    cliOwner.flush();
+    await assert.isFulfilled(owner.applyUserActions(docId, [
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: 1, aclFormula: 'newRec.A != 1', permissionsText: '-U',
+      }],
+    ]));
+    assert.equal((await cliEditor.readMessage()).type, 'docShutdown');
+    assert.equal((await cliOwner.readMessage()).type, 'docShutdown');
+    await assert.isRejected(editor.applyUserActions(docId, [
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: 1, aclFormula: 'newRec.A != 1', permissionsText: '-U',
+      }],
+    ]), /Only owners can modify access rules/);
+    await assert.isFulfilled(owner.applyUserActions(docId, [
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: 1, aclFormula: 'user.Access != "owners"', permissionsText: '-R',
+      }]
+    ]));
+
+    cliEditor.flush();
+    cliOwner.flush();
+    await assert.isFulfilled(owner.applyUserActions(docId, [
+      ['RenameTable', 'Data1', 'Data2'],
+    ]));
+    assert.deepEqual(await cliOwner.readDocUserAction(), [
+      [ 'RenameTable', 'Data1', 'Data2' ],
+      [ 'UpdateRecord', '_grist_Tables', 2, { tableId: 'Data2' } ],
+      [ 'UpdateRecord', '_grist_ACLResources', 2, { tableId: 'Data2' } ]
+    ]);
+    assert.deepEqual(await cliEditor.readDocUserAction(), [
+      [ 'RenameTable', 'Data1', 'Data2' ],
+      [ 'UpdateRecord', '_grist_Tables', 2, { tableId: 'Data2' } ]
+    ]);
+
+    // Editor cannot download doc with some private info.
+    await assert.isRejected((await editor.getWorkerAPI(docId)).downloadDoc(docId));
+
+    // Grant editor special access to access rules.
+    await owner.applyUserActions(docId, [
+      ['AddRecord', '_grist_ACLResources', -1, {tableId: '*SPECIAL', colIds: 'AccessRules'}],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -1, aclFormula: 'user.Access == "editors"', permissionsText: '+R',
+      }],
+    ]);
+    cliEditor.flush();
+    cliOwner.flush();
+    await assert.isFulfilled(owner.applyUserActions(docId, [
+      ['RenameTable', 'Data2', 'Data3'],
+    ]));
+    for (const cli of [cliEditor, cliOwner]) {
+      assert.deepEqual(await cli.readDocUserAction(), [
+        [ 'RenameTable', 'Data2', 'Data3' ],
+        [ 'UpdateRecord', '_grist_Tables', 2, { tableId: 'Data3' } ],
+        [ 'UpdateRecord', '_grist_ACLResources', 2, { tableId: 'Data3' } ]
+      ]);
+    }
+    // Editor still cannot download doc.
+    await assert.isRejected((await editor.getWorkerAPI(docId)).downloadDoc(docId));
+
+    // Grant editor special access to download document.
+    await owner.applyUserActions(docId, [
+      ['AddRecord', '_grist_ACLResources', -1, {tableId: '*SPECIAL', colIds: 'FullCopies'}],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -1, aclFormula: 'user.Access == "editors"', permissionsText: '+R',
+      }],
+    ]);
+
+    // Download should work, and have FullCopies rules/resources removed.
+    const download = await (await editor.getWorkerAPI(docId)).downloadDoc(docId);
+    const worker = await editor.getWorkerAPI('import');
+    const uploadId = await worker.upload(await (download as any).buffer(), 'upload.grist');
+    const workspaceId = (await editor.getOrgWorkspaces('current'))[0].id;
+    const copyDocId = (await worker.importDocToWorkspace(uploadId, workspaceId)).id;
+    assert.deepEqual(await editor.getDocAPI(copyDocId).getRows('_grist_ACLResources'),
+                     { id: [ 1, 2, 3, 4 ],
+                       colIds: [ '', '*', '*', 'AccessRules' ],
+                       tableId: [ '', 'Data3', 'Sensitive', '*SPECIAL' ] });
+    assert.deepEqual((await editor.getDocAPI(copyDocId).getRows('_grist_ACLRules')).resource,
+                     [ 1, 2, 3, 1, 1, 4 ]);
+
+    // Similarly for a fork.
+    cliEditor.flush();
+    const forkDocId = (await cliEditor.send("fork", 0)).data.docId as string;
+    assert.deepEqual(await editor.getDocAPI(forkDocId).getRows('_grist_ACLResources'),
+                     { id: [ 1, 2, 3, 4 ],
+                       colIds: [ '', '*', '*', 'AccessRules' ],
+                       tableId: [ '', 'Data3', 'Sensitive', '*SPECIAL' ] });
+    assert.deepEqual((await editor.getDocAPI(copyDocId).getRows('_grist_ACLRules')).resource,
+                     [ 1, 2, 3, 1, 1, 4 ]);
+
+    // Original doc should be unchanged.
+    assert.deepEqual(await editor.getDocAPI(docId).getRows('_grist_ACLResources'),
+                     { id: [ 1, 2, 3, 4, 5 ],
+                       colIds: [ '', '*', '*', 'AccessRules', 'FullCopies' ],
+                       tableId: [ '', 'Data3', 'Sensitive', '*SPECIAL', '*SPECIAL' ] });
+    assert.deepEqual((await editor.getDocAPI(docId).getRows('_grist_ACLRules')).resource,
+                     [ 1, 2, 3, 1, 1, 4, 5 ]);
+  });
+
+  it('handles fork ownership gracefully', async function() {
+    // Make a document with some data only owners have access to.
+    await freshDoc();
+    await owner.applyUserActions(docId, [
+      ['AddTable', 'Data1', [{id: 'A', type: 'Numeric'}]],
+      ['AddRecord', 'Data1', 1, {A: 14}],
+      ['AddRecord', 'Data1', 2, {A: 15}],
+      ['AddTable', 'Sensitive', [{id: 'A', type: 'Numeric'}]],
+      ['AddRecord', 'Sensitive', 1, {A: 16}],
+      ['AddRecord', 'Sensitive', 2, {A: 17}],
+      ['AddRecord', '_grist_ACLResources', -1, {tableId: 'Sensitive', colIds: '*'}],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -1, aclFormula: 'user.Access != "owners"', permissionsText: 'none',
+      }],
+    ]);
+
+    // Check editor can write to public table in regular document mode.
+    assert.equal((await cliEditor.send('applyUserActions', 0,
+                                       [['AddRecord', 'Data1', null, {A: 99}]])).error,
+                 undefined);
+    // Check editor cannot read sensitive data.
+    assert.match((await cliEditor.send('fetchTable', 0, 'Sensitive')).error!,
+                 /Blocked by table read access rules/);
+    // Check that in fork mode, editor still cannot read sensitive data.
+    await reopenClients({openMode: 'fork'});
+    assert.match((await cliEditor.send('fetchTable', 0, 'Sensitive')).error!,
+                 /Blocked by table read access rules/);
+    // Nor can editor write in (pre)-fork mode.  Need to send an explicit "fork" command
+    // to create a different doc to write to (tested elsewhere).
+    assert.match((await cliEditor.send('applyUserActions', 0,
+                                       [['AddRecord', 'Data1', null, {A: 99}]])).error!,
+                 /No write access/);
+
+    // Grant editor special access to copy/download/fork document.
+    await owner.applyUserActions(docId, [
+      ['AddRecord', '_grist_ACLResources', -1, {tableId: '*SPECIAL', colIds: 'FullCopies'}],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -1, aclFormula: 'user.Access == "editors"', permissionsText: '+R',
+      }],
+    ]);
+
+    // Check editor can still write to public table in regular document mode.
+    await reopenClients();
+    assert.equal((await cliEditor.send('applyUserActions', 0,
+                                       [['AddRecord', 'Data1', null, {A: 99}]])).error,
+                 undefined);
+    // Editor still cannot read sensitive data in regular mode (although they could download
+    // it, tested elsewhere).
+    assert.match((await cliEditor.send('fetchTable', 0, 'Sensitive')).error!,
+                 /Blocked by table read access rules/);
+
+    // But now, if opening in fork mode, editor reads as owner, as if they' already
+    // copied everything and become its owner.
+    await reopenClients({openMode: 'fork'});
+    assert.deepEqual((await cliEditor.send('fetchTable', 0, 'Sensitive')).data.tableData[3],
+                     { manualSort: [ 1, 2 ], A: [ 16, 17 ] });
+    // Modifications remain forbidden.  Were we to send the 'fork' message,
+    // (tested elsewhere) we'd get back a new docId to switch to, and there
+    // the editor would be a true owner.
+    assert.match((await cliEditor.send('applyUserActions', 0,
+                                       [['AddRecord', 'Data1', null, {A: 99}]])).error!,
+                 /No write access/);
+  });
+
+  it('handles outgoing actions when an action triggers changes in other tables', async function() {
+    await freshDoc();
+
+    // Set up a situation where there are two linked tables (a change to Contacts will trigger a
+    // change to Interactions), and one table has partial access.
+    await owner.applyUserActions(docId, [
+      ['AddTable', 'Contacts', [{id: 'Name', type: 'Text'}, {id: 'Show', type: 'Bool'}]],
+      ['AddTable', 'Interactions', [
+        {id: 'Contact', type: 'Ref:Contacts'},
+        {id: 'ContactName', formula: '$Contact.Name'},
+      ]],
+      ['AddRecord', 'Contacts', -1, {Name: 'Bob', Show: true}],
+      ['AddRecord', 'Contacts', -2, {Name: 'Jane', Show: false}],
+      ['AddRecord', 'Interactions', -1, {Contact: -1}],
+      ['AddRecord', 'Interactions', -2, {Contact: -1}],
+      ['AddRecord', 'Interactions', -3, {Contact: -2}],
+
+      ['AddRecord', '_grist_ACLResources', -1, {tableId: 'Contacts', colIds: '*'}],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -1, aclFormula: 'user.Access != "owners" and not rec.Show', permissionsText: 'none',
+      }],
+    ]);
+
+    // Connect an editor, so that there is someone to receive filtered outgoing actions.
+    cliEditor.flush();
+
+    // Make a change that triggers an update to two different tables. It should succeed.
+    await assert.isFulfilled(editor.getDocAPI(docId).updateRows('Contacts', {id: [1], Name: ['Bert']}));
+
+    // Read the broadcast action, and check that it includes both expected updates.
+    const docAction1 = await cliEditor.readDocUserAction();
+    assert.deepEqual(docAction1, [
+      ['UpdateRecord', 'Contacts', 1, { Name: 'Bert' }],
+      ['BulkUpdateRecord', 'Interactions', [ 1, 2 ], { ContactName: ['Bert', 'Bert'] }],
+    ]);
+
+    // As a secondary test, check that the edit restriction works.
+    await assert.isRejected(editor.getDocAPI(docId).updateRows('Contacts', {id: [2], Name: ['Jennifer']}),
+      /Blocked by row update access rules/);
+
+    // Check that it didn't trigger a broadcast.
+    assert.equal(await isLongerThan(cliEditor.readDocUserAction(), 500), true);
+  });
+
+  it('restricts helper columns of restricted user columns', async function() {
+    await freshDoc();
+
+    await owner.applyUserActions(docId, [
+      ['AddTable', 'Contacts', [{id: 'Name', type: 'Text'}]],
+      ['AddTable', 'Interactions', [
+        {id: 'Contact', type: 'Ref:Contacts'},
+        {id: 'Show', type: 'Bool'},
+      ]],
+
+      ['AddRecord', 'Contacts', 1, {Name: 'Bob'}],
+      ['AddRecord', 'Contacts', 2, {Name: 'Jane'}],
+      ['AddRecord', 'Interactions', 3, {Contact: 1, Show: true}],
+      ['AddRecord', 'Interactions', 4, {Contact: 2, Show: false}],
+
+      ['AddRecord', '_grist_ACLResources', -1, {tableId: 'Interactions', colIds: 'Contact'}],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -1, aclFormula: 'user.Access != "owners" and not rec.Show', permissionsText: 'none',
+      }],
+    ]);
+
+    cliOwner.flush();
+    cliEditor.flush();
+
+    await owner.applyUserActions(docId, [
+      // Give Interactions.Contact a display column...
+      ['SetDisplayFormula', 'Interactions', null, 8, '$Contact.Name'],
+
+      // ...and a conditional formatting rule column
+      ['AddEmptyRule', 'Interactions', 0, 8],
+      ['UpdateRecord', '_grist_Tables_column', 11, {'formula': '$Contact.Name == "Bob"'}],
+
+      // Repeat the same for a *field* that uses that column
+      ['SetDisplayFormula', 'Interactions', 13, null, '$Contact.Name + "2"'],
+      ['AddEmptyRule', 'Interactions', 13, 0],
+      ['UpdateRecord', '_grist_Tables_column', 13, {'formula': '$Contact.Name == "Jane"'}],
+    ]);
+
+    assert.deepEqual(
+      (await cliOwner.readDocUserAction()).slice(-4),
+      [
+        ["BulkUpdateRecord", "Interactions", [3, 4], {"gristHelper_ConditionalRule": [true, false]}],
+        ["BulkUpdateRecord", "Interactions", [3, 4], {"gristHelper_ConditionalRule2": [false, true]}],
+        ["BulkUpdateRecord", "Interactions", [3, 4], {"gristHelper_Display": ["Bob", "Jane"]}],
+        ["BulkUpdateRecord", "Interactions", [3, 4], {"gristHelper_Display2": ["Bob2", "Jane2"]}],
+      ],
+    );
+
+    // The helper columns are censored for the editor.
+    // They shouldn't actually be 100% censored in outgoing actions,
+    // this is a limitation with formulas involving `rec`.
+    // When fetching records as below, they're correctly partially censored.
+    const censoreds: CellValue[] = [[GristObjCode.Censored], [GristObjCode.Censored]];
+    assert.deepEqual(
+      (await cliEditor.readDocUserAction()).slice(-4),
+      [
+        ["BulkUpdateRecord", "Interactions", [3, 4], {"gristHelper_ConditionalRule": censoreds}],
+        ["BulkUpdateRecord", "Interactions", [3, 4], {"gristHelper_ConditionalRule2": censoreds}],
+        ["BulkUpdateRecord", "Interactions", [3, 4], {"gristHelper_Display": censoreds}],
+        ["BulkUpdateRecord", "Interactions", [3, 4], {"gristHelper_Display2": censoreds}],
+      ],
+    );
+
+    // Check that the columns were added correctly
+    const columns = await owner.getDocAPI(docId).getRecords("_grist_Tables_column");
+    assert.isTrue(
+      isMatch(columns, [
+        // Table1
+        {id: 1}, {id: 2}, {id: 3}, {id: 4},
+
+        // Contacts
+        {id: 5, fields: {parentId: 2, colId: 'manualSort'}},
+        {id: 6, fields: {parentId: 2, colId: 'Name', type: 'Text'}},
+
+        // Interactions
+        {id: 7, fields: {parentId: 3, colId: 'manualSort'}},
+        {id: 8, fields: {parentId: 3, colId: 'Contact', type: 'Ref:Contacts', displayCol: 10, rules: ['L', 11]}},
+        {id: 9, fields: {parentId: 3, colId: 'Show', type: 'Bool'}},
+        {id: 10, fields: {parentId: 3, colId: 'gristHelper_Display', type: 'Any', formula: '$Contact.Name'}},
+        {
+          id: 11, fields: {
+            parentId: 3, colId: 'gristHelper_ConditionalRule', type: 'Any', formula: '$Contact.Name == "Bob"'
+          }
+        },
+        {id: 12, fields: {parentId: 3, colId: 'gristHelper_Display2', type: 'Any', formula: '$Contact.Name + "2"'}},
+        {
+          id: 13, fields: {
+            parentId: 3, colId: 'gristHelper_ConditionalRule2', type: 'Any', formula: '$Contact.Name == "Jane"'
+          }
+        },
+      ]),
+      "Unexpected columns: " + JSON.stringify(columns, null, 4),
+    );
+
+    // Check that the field is also correct
+    const fields = await owner.getDocAPI(docId).getRecords("_grist_Views_section_field");
+    assert.isTrue(
+      isMatch(fields[12], {id: 13, fields: {colRef: 8, displayCol: 12, rules: ['L', 13]}}),
+      "Unexpected fields: " + JSON.stringify(fields, null, 4),
+    );
+
+    const commonColumns = {
+      id: [3, 4],
+      manualSort: [1, 2],
+      Show: [true, false],
+    };
+
+    const ownerRows = await owner.getDocAPI(docId).getRows("Interactions");
+    assert.deepEqual(ownerRows, {
+      ...commonColumns,
+      Contact: [1, 2],
+      gristHelper_Display: ['Bob', 'Jane'],
+      gristHelper_Display2: ['Bob2', 'Jane2'],
+      gristHelper_ConditionalRule: [true, false],
+      gristHelper_ConditionalRule2: [false, true],
+    });
+
+    const editorRows = await editor.getDocAPI(docId).getRows("Interactions");
+    assert.deepEqual(editorRows, {
+      ...commonColumns,
+      Contact: [1, [GristObjCode.Censored]],
+      // Helper columns are censored in tandem with the associated user column
+      gristHelper_Display: ['Bob', [GristObjCode.Censored]],
+      gristHelper_Display2: ['Bob2', [GristObjCode.Censored]],
+      gristHelper_ConditionalRule: [true, [GristObjCode.Censored]],
+      gristHelper_ConditionalRule2: [false, [GristObjCode.Censored]],
+    });
+  });
+
+  it('respects row-level access control on creates (without formulas)', async function() {
+    await freshDoc();
+    // Make a table, and allow creation of rows only matching a condition.
+    await owner.applyUserActions(docId, [
+      ['AddTable', 'Data1', [{id: 'A', type: 'Numeric'},
+                             {id: 'B', type: 'Numeric'}]],
+      ['AddRecord', 'Data1', null, {A: 100, B: 50}],
+      ['AddRecord', 'Data1', null, {A: 200, B: 150}],
+      ['AddRecord', 'Data1', null, {A: 300, B: 250}],
+
+      ['AddRecord', '_grist_ACLResources', -1, {tableId: 'Data1', colIds: '*'}],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -1, aclFormula: 'user.Access != "owners" and newRec.A <= newRec.B', permissionsText: '-C',
+      }],
+    ]);
+    assert.equal((await owner.getDocAPI(docId).getRows('Data1')).id.length, 3);
+    await assert.isFulfilled(editor.getDocAPI(docId).addRows(
+      'Data1', { A: [10], B: [1] }));
+    assert.equal((await owner.getDocAPI(docId).getRows('Data1')).id.length, 4);
+    await assert.isRejected(editor.getDocAPI(docId).addRows(
+      'Data1', { A: [1], B: [10] }));
+    assert.equal((await owner.getDocAPI(docId).getRows('Data1')).id.length, 4);
+  });
+
+  it('respects row-level access control on creates (with formulas)', async function() {
+    await freshDoc();
+    // Make a table, and allow creation of rows only matching a condition.
+    await owner.applyUserActions(docId, [
+      ['AddTable', 'Data1', [{id: 'A', type: 'Numeric'},
+                             {id: 'B', type: 'Numeric'},
+                             {id: 'Good', isFormula: true, formula: '$A > $B'}]],
+      ['AddRecord', 'Data1', null, {A: 100, B: 50}],
+      ['AddRecord', 'Data1', null, {A: 200, B: 150}],
+      ['AddRecord', 'Data1', null, {A: 300, B: 250}],
+
+      ['AddRecord', '_grist_ACLResources', -1, {tableId: 'Data1', colIds: '*'}],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -1, aclFormula: 'user.Access != "owners" and not newRec.Good', permissionsText: '-C',
+      }],
+    ]);
+    assert.equal((await owner.getDocAPI(docId).getRows('Data1')).id.length, 3);
+    await assert.isFulfilled(editor.getDocAPI(docId).addRows(
+      'Data1', { A: [10], B: [1] }));
+    assert.equal((await owner.getDocAPI(docId).getRows('Data1')).id.length, 4);
+    await assert.isRejected(editor.getDocAPI(docId).addRows(
+      'Data1', { A: [1], B: [10] }));
+    assert.equal((await owner.getDocAPI(docId).getRows('Data1')).id.length, 4);
+  });
+
+  it('respects row-level access control on deletes', async function() {
+    await freshDoc();
+    // Make a table, and allow creation of rows only matching a condition.
+    await owner.applyUserActions(docId, [
+      ['AddTable', 'Data1', [{id: 'A', type: 'Numeric'},
+                             {id: 'B', type: 'Numeric'},
+                             {id: 'Good', isFormula: true, formula: '$A > $B'}]],
+      ['AddRecord', 'Data1', null, {A: 100, B: 50}],
+      ['AddRecord', 'Data1', null, {A: 200, B: 250}],
+      ['AddRecord', 'Data1', null, {A: 300, B: 250}],
+
+      ['AddRecord', '_grist_ACLResources', -1, {tableId: 'Data1', colIds: '*'}],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -1, aclFormula: 'user.Access != "owners" and not rec.Good', permissionsText: '-D',
+      }],
+    ]);
+    assert.equal((await owner.getDocAPI(docId).getRows('Data1')).id.length, 3);
+    await assert.isFulfilled(editor.getDocAPI(docId).removeRows(
+      'Data1', [1]));
+    assert.equal((await owner.getDocAPI(docId).getRows('Data1')).id.length, 2);
+    await assert.isRejected(editor.getDocAPI(docId).removeRows(
+      'Data1', [2]));
+    assert.equal((await owner.getDocAPI(docId).getRows('Data1')).id.length, 2);
+  });
+
+  it('can prevent duplicates', async function() {
+    await freshDoc();
+    // Make a table, and allow creation or update of rows with unique keys.
+    await owner.applyUserActions(docId, [
+      ['AddTable', 'Data1', [{id: 'A', type: 'Numeric'},
+                             {id: 'Count', isFormula: true, formula: 'len(Data1.lookupRecords(A=$A))'}]],
+      ['AddRecord', 'Data1', null, {A: 100}],
+      ['AddRecord', 'Data1', null, {A: 200}],
+      ['AddRecord', 'Data1', null, {A: 300}],
+
+      ['AddRecord', '_grist_ACLResources', -1, {tableId: 'Data1', colIds: '*'}],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -1,
+        aclFormula: 'newRec.Count > 1',
+        permissionsText: '-CU',
+        memo: 'duplicate check',
+      }],
+    ]);
+
+    const noop = assertUnchanged(() => owner.getDocAPI(docId).getRows('Data1'));
+
+    // Adding a row with a distinct key should work.
+    assert.equal((await owner.getDocAPI(docId).getRows('Data1')).id.length, 3);
+    await assert.isFulfilled(owner.getDocAPI(docId).addRows('Data1', { A: [400] }));
+    assert.equal((await owner.getDocAPI(docId).getRows('Data1')).id.length, 4);
+    // Adding a row with a duplicated key should fail.
+    await noop(assertDeniedFor(owner.getDocAPI(docId).addRows( 'Data1', { A: [200] }),
+                               ['duplicate check']));
+    assert.equal((await owner.getDocAPI(docId).getRows('Data1')).id.length, 4);
+    // If original is removed, adding the row should now succeed.
+    await assert.isFulfilled(owner.getDocAPI(docId).removeRows('Data1', [2]));
+    await assert.isFulfilled(owner.getDocAPI(docId).addRows('Data1', { A: [200] }));
+    // Updating a row to duplicate an existing key should fail.
+    await noop(assert.isRejected(owner.getDocAPI(docId).updateRows('Data1',
+                                                                         { id: [1], A: [200] })));
+    // Updating a row to have a new key should succeed.
+    await assert.isFulfilled(owner.getDocAPI(docId).updateRows('Data1',
+                                                               { id: [1], A: [500] }));
+    // Adding rows containing a new duplicate should fail.
+    await noop(assert.isRejected(owner.getDocAPI(docId).addRows('Data1', { A: [600, 600] })));
+
+    // A duplicate introduced within an action bundle should cause the bundle to be rejected.
+    await noop(assert.isRejected(owner.applyUserActions(docId, [
+      ['AddRecord', 'Data1', null, {A: 700}],
+      ['UpdateRecord', 'Data1', 1, {A: 700}],
+    ])));
+
+    // An action bundle should otherwise succeed.
+    await assert.isFulfilled(owner.applyUserActions(docId, [
+      ['AddRecord', 'Data1', null, {A: 800}],
+      ['UpdateRecord', 'Data1', 1, {A: 700}],
+    ]));
+
+    // Adding 700 at this point should be rejected as a duplicate.
+    await noop(assert.isRejected(owner.applyUserActions(docId, [
+      ['AddRecord', 'Data1', -1, {A: 700}],
+    ])));
+
+    // Adding 700 and immediately overwriting should be accepted.
+    await assert.isFulfilled(owner.applyUserActions(docId, [
+      ['AddRecord', 'Data1', -1, {A: 700}],
+      ['UpdateRecord', 'Data1', -1, {A: 750}],
+    ]));
+
+    // Again, a duplicate introduced in a bundle should be rejected.
+    await noop(assert.isRejected(owner.applyUserActions(docId, [
+      ['AddRecord', 'Data1', -1, {A: 760}],
+      ['UpdateRecord', 'Data1', -1, {A: 750}],
+    ])));
+  });
+
+  it('permits indirect changes via formulas', async function() {
+    await freshDoc();
+
+    // Make a table with a data column A, and a formula column Count.
+    await owner.applyUserActions(docId, [
+      ['AddTable', 'Data1', [{id: 'A', type: 'Numeric'},
+                             {id: 'Count', isFormula: true, formula: 'len(Data1.lookupRecords(A=$A))'}]],
+      ['AddRecord', 'Data1', null, {A: 100}],
+      ['AddRecord', 'Data1', null, {A: 200}],
+      ['AddRecord', 'Data1', null, {A: 300}],
+
+      // Forbid write access to Count (this is redundant since the data engine forbids
+      // writing to a formula column in any case).
+      ['AddRecord', '_grist_ACLResources', -1, {tableId: 'Data1', colIds: 'Count'}],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -1, aclFormula: 'True', permissionsText: '-U',
+      }],
+    ]);
+
+    // Check initial state of formula column.
+    assert.deepEqual((await owner.getDocAPI(docId).getRows('Data1')).Count, [1, 1, 1]);
+
+    // Make a change in data column.
+    await assert.isFulfilled(owner.getDocAPI(docId).updateRows('Data1',
+                                                               { id: [1], A: [200] }));
+
+    // Check that formula column changed as expected.
+    assert.deepEqual((await owner.getDocAPI(docId).getRows('Data1')).Count, [2, 2, 1]);
+
+    // Check that we cannot write to the formula column.
+    await assert.isRejected(owner.getDocAPI(docId).updateRows('Data1',
+                                                              { id: [1], Count: [200] }),
+                            /Can't save value to formula column/);
+  });
+
+  it('permits indirect changes via type conversion', async function() {
+    await freshDoc();
+
+    // Make a table with a data column A, and make it read-only.
+    await owner.applyUserActions(docId, [
+      ['AddTable', 'Data1', [{id: 'A', type: 'Int'}]],
+      ['AddRecord', 'Data1', null, {A: 100}],
+      ['AddRecord', 'Data1', null, {A: 200}],
+      ['AddRecord', 'Data1', null, {A: 300}],
+
+      // Forbid write access to column.
+      ['AddRecord', '_grist_ACLResources', -1, {tableId: 'Data1', colIds: 'A'}],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -1,
+        aclFormula: 'True',
+        permissionsText: '-CUD',
+        memo: 'COMPUTER SAYS NO'
+      }],
+    ]);
+
+    // Check initial state of column.
+    assert.deepEqual((await owner.getDocAPI(docId).getRows('Data1')).A, [100, 200, 300]);
+
+    // Try to make a change in data column.
+    await assertDeniedFor(owner.getDocAPI(docId).updateRows('Data1',
+                                                              { id: [1], A: [200] }),
+                          ['COMPUTER SAYS NO']);
+
+    // Convert column in bulk - we have +S bit so we can do this.
+    await owner.applyUserActions(docId, [
+      ["ModifyColumn", "Data1", "A", {"type": "Text"}]
+    ]);
+
+    // Check that column changed as expected.
+    assert.deepEqual((await owner.getDocAPI(docId).getRows('Data1')).A, ['100', '200', '300']);
+  });
+
+  it('permits indirect changes via simple summary tables', async function() {
+    await freshDoc();
+
+    // Make test tables.
+    await owner.applyUserActions(docId, [
+      ['AddTable', 'Data1', [{id: 'G', type: 'Numeric'}, {id: 'V', type: 'Numeric'}]],
+      ['AddRecord', 'Data1', null, {G: 1, V: 10}],
+      ['AddRecord', 'Data1', null, {G: 2, V: 20}],
+      ['AddRecord', 'Data1', null, {G: 2, V: 20}],
+      ['AddTable', 'Data2', [{id: 'A', type: 'Numeric'}]],
+    ]);
+
+    // Get tableRef and colRef of column 'G' so we can make a summary table.
+    const tableRef = (await owner.getDocAPI(docId).getRows('_grist_Tables',
+                                                           {filters: { tableId: ['Data1'] }})).id[0];
+    const colRef = (await owner.getDocAPI(docId).getRows('_grist_Tables_column',
+                                                         {filters: { colId: ['G'] }})).id[0];
+
+    // Make a summary table.
+    await owner.applyUserActions(docId, [
+      ['CreateViewSection', tableRef, 0, 'detail', [colRef], null]
+    ]);
+
+    // Allow non-owners to edit data table only, not summary table.
+    await owner.applyUserActions(docId, [
+      ['AddRecord', '_grist_ACLResources', -1, {tableId: '*', colIds: '*'}],
+      ['AddRecord', '_grist_ACLResources', -2, {tableId: 'Data1', colIds: '*'}],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -1, aclFormula: 'user.Access != OWNER', permissionsText: '-CUD',
+      }],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -2, aclFormula: 'user.Access != OWNER', permissionsText: '+CUD',
+      }],
+    ]);
+
+    // Check summary looks as expected.
+    assert.deepEqual((await owner.getDocAPI(docId).getRows('Data1_summary_G')).V, [10, 40]);
+
+    // Make sure that editor can indirectly create a new row in summary, despite access rules.
+    await editor.applyUserActions(docId, [
+      ['AddRecord', 'Data1', null, {G: 3, V: 5}],
+    ]);
+    assert.deepEqual((await owner.getDocAPI(docId).getRows('Data1_summary_G')).V, [10, 40, 5]);
+
+    // Make sure that editor can indirectly hide a row in summary.
+    await editor.applyUserActions(docId, [
+      ['UpdateRecord', 'Data1', 1, {G: 3}],
+    ]);
+    assert.deepEqual((await owner.getDocAPI(docId).getRows('Data1_summary_G')).V, [40, 15]);
+
+    // Make sure editor cannot directly change Data2.
+    await assert.isRejected(editor.applyUserActions(docId, [
+      ['AddRecord', 'Data2', null, {A: 1}],
+    ]), /Blocked by table create access rules/);
+  });
+
+  it('permits indirect changes via flattened summary tables', async function() {
+    await freshDoc();
+
+    // Make test tables.
+    await owner.applyUserActions(docId, [
+      ['AddTable', 'Data1', [{id: 'G', type: 'ChoiceList'}, {id: 'V', type: 'Numeric'}]],
+      ['AddRecord', 'Data1', null, {G: ['L', 1, 2], V: 10}],
+      ['AddRecord', 'Data1', null, {G: ['L', 2], V: 20}],
+      ['AddRecord', 'Data1', null, {G: ['L', 2], V: 20}],
+      ['AddTable', 'Data2', [{id: 'A', type: 'Numeric'}]],
+    ]);
+
+    // Get tableRef and colRef of column 'G' so we can make a summary table.
+    const tableRef = (await owner.getDocAPI(docId).getRows('_grist_Tables',
+                                                           {filters: { tableId: ['Data1'] }})).id[0];
+    const colRef = (await owner.getDocAPI(docId).getRows('_grist_Tables_column',
+                                                         {filters: { colId: ['G'] }})).id[0];
+
+    // Make a summary table.
+    await owner.applyUserActions(docId, [
+      ['CreateViewSection', tableRef, 0, 'detail', [colRef], null]
+    ]);
+
+    // Block create/update/delete to non-owners on summary table.
+    // Allow non-owners to edit data table only, not summary table.
+    await owner.applyUserActions(docId, [
+      ['AddRecord', '_grist_ACLResources', -1, {tableId: '*', colIds: '*'}],
+      ['AddRecord', '_grist_ACLResources', -2, {tableId: 'Data1', colIds: '*'}],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -1, aclFormula: 'user.Access != OWNER', permissionsText: '-CUD',
+      }],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -2, aclFormula: 'user.Access != OWNER', permissionsText: '+CUD',
+      }],
+    ]);
+
+    // Check summary looks as expected.
+    assert.deepEqual((await owner.getDocAPI(docId).getRows('Data1_summary_G')).V, [10, 50]);
+
+    // Make sure that editor can indirectly create a new row in summary, despite access rules.
+    await editor.applyUserActions(docId, [
+      ['AddRecord', 'Data1', null, {G: ['L', 2, 3, 4], V: 5}],
+    ]);
+    assert.deepEqual((await owner.getDocAPI(docId).getRows('Data1_summary_G')).V, [10, 55, 5, 5]);
+
+    // Make sure that editor can indirectly hide a row in summary.
+    await editor.applyUserActions(docId, [
+      ['UpdateRecord', 'Data1', 1, {G: ['L', 3]}],
+    ]);
+    assert.deepEqual((await owner.getDocAPI(docId).getRows('Data1_summary_G')).V, [45, 15, 5]);
+
+    // Make sure editor cannot directly change Data2.
+    await assert.isRejected(editor.applyUserActions(docId, [
+      ['AddRecord', 'Data2', null, {A: 1}],
+    ]), /Blocked by table create access rules/);
+  });
+
+  it('uncensors the raw view section of a source table when a summary table is visible', async function() {
+    await freshDoc();
+    const docApi = owner.getDocAPI(docId);
+
+    // The doc starts out with one table by default, with three view sections (widgets): one 'normal',
+    // one raw, and one record card.
+    // Initially, they have no titles. Give them some. Note that naming the raw section 'My Data'
+    // also renames the table itself to 'My_Data'.
+    await docApi.updateRows('_grist_Views_section', {id: [1, 2], title: ['Widget', 'My Data']});
+
+    // Check the initial tableId and title values.
+    let tableIds = (await docApi.getRows('_grist_Tables')).tableId;
+    let sectionTitles = (await docApi.getRows('_grist_Views_section')).title;
+    assert.deepEqual(tableIds, ['My_Data']);
+    assert.deepEqual(sectionTitles, ['Widget', 'My Data', '']);
+
+    // Deny all access to the table.
+    await owner.applyUserActions(docId, [
+      ['AddRecord', '_grist_ACLResources', -1, {tableId: 'My_Data', colIds: '*'}],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -1, aclFormula: null, permissionsText: '-CRUD',
+      }],
+    ]);
+
+    // Now all those values are 'censored', i.e. blank.
+    tableIds = (await docApi.getRows('_grist_Tables')).tableId;
+    sectionTitles = (await docApi.getRows('_grist_Views_section')).title;
+    assert.deepEqual(tableIds, ['']);
+    assert.deepEqual(sectionTitles, ['', '', '']);
+
+    // Make a summary table on the table grouped by column 'A'.
+    await owner.applyUserActions(docId, [
+      ['CreateViewSection', 1, 0, 'detail', [2], null]
+    ]);
+
+    // Get the values again.
+    tableIds = (await docApi.getRows('_grist_Tables')).tableId;
+    sectionTitles = (await docApi.getRows('_grist_Views_section')).title;
+
+    // The source tableId is still hidden, and we now have a new summary table.
+    assert.deepEqual(tableIds, ['', 'My_Data_summary_A']);
+
+    assert.deepEqual(sectionTitles, [
+      // Source table sections. The normal section is still hidden, but the raw section title is revealed.
+      '', 'My Data', '',
+      // Summary table sections. These aren't hidden, they just have no titles.
+      '', '',
+    ]);
+  });
+
+  it('merges rec and newRec for creations and deletions', async function() {
+    await freshDoc();
+
+    // Make a table with a data column A, and allow user to add/remove odd rows.
+    await owner.applyUserActions(docId, [
+      ['AddTable', 'Data1', [{id: 'A', type: 'Int'}]],
+      ['AddRecord', 'Data1', null, {A: 100}],
+      ['AddRecord', 'Data1', null, {A: 201}],
+      ['AddRecord', 'Data1', null, {A: 301}],
+
+      // Forbid write access to column.
+      ['AddRecord', '_grist_ACLResources', -1, {tableId: 'Data1', colIds: '*'}],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -1, aclFormula: 'rec.A % 2 == 0', permissionsText: '-CD', memo: 'STOP1',
+      }],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -1, aclFormula: 'newRec.A % 2 == 0', permissionsText: '-CD', memo: 'STOP2',
+      }],
+    ]);
+
+    // Cannot add a row with even A.
+    await assertDeniedFor(owner.getDocAPI(docId).addRows('Data1', {A: [500]}),
+                          ['STOP1', 'STOP2']);
+
+    // Cannot remove a row with even A.
+    await assertDeniedFor(owner.getDocAPI(docId).removeRows('Data1', [1]),
+                          ['STOP1', 'STOP2']);
+
+    // Can add a row with odd A.
+    await assert.isFulfilled(owner.getDocAPI(docId).addRows('Data1', {A: [501]}));
+
+    // Can remove a row with odd A.
+    await assert.isFulfilled(owner.getDocAPI(docId).removeRows('Data1', [2]));
+  });
+
+  it('newRec behavior in a long or mixed bundle is as expected', async function() {
+    await freshDoc();
+    await owner.applyUserActions(docId, [
+      ['AddTable', 'Data1', [{id: 'A', type: 'Numeric'},
+                             {id: 'B', isFormula: true, formula: '$A + 1'},
+                             {id: 'C'}]],
+      ['AddRecord', 'Data1', null, {A: 101}],
+      ['AddRecord', 'Data1', null, {A: 201}],
+      ['AddRecord', 'Data1', null, {A: 301}],
+      ['AddRecord', 'Data1', null, {A: 401}],
+      ['AddRecord', 'Data1', null, {A: 501}],
+      ['AddTable', 'Data2', [{id: 'A', type: 'Numeric'},
+                             {id: 'B', isFormula: true, formula: '$A + 1'}]],
+      ['AddRecord', 'Data2', null, {A: 101}],
+      ['AddRecord', 'Data2', null, {A: 201}],
+      ['AddRecord', 'Data2', null, {A: 301}],
+      ['AddRecord', 'Data2', null, {A: 401}],
+
+      ['AddRecord', '_grist_ACLResources', -1, {tableId: 'Data1', colIds: '*'}],
+      ['AddRecord', '_grist_ACLResources', -2, {tableId: 'Data2', colIds: '*'}],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -1, aclFormula: 'newRec.B % 2 != 0', permissionsText: '-CU',
+      }],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -2, aclFormula: 'newRec.B % 2 != 0', permissionsText: '-CU',
+      }],
+    ]);
+
+    // It is ok for rows to temporarily disobey newRec constraint.
+    await assert.isFulfilled(owner.applyUserActions(docId, [
+      ['UpdateRecord', 'Data1', 1, {A: 91}],
+      ['UpdateRecord', 'Data1', 2, {A: 92}],
+      ['UpdateRecord', 'Data1', 2, {A: 93}],
+      ['UpdateRecord', 'Data1', 3, {A: 94}],
+      ['UpdateRecord', 'Data2', 4, {A: 96}],
+      ['UpdateRecord', 'Data2', 4, {A: 97}],
+      ['AddRecord', 'Data2', 5, {}],
+      ['UpdateRecord', 'Data2', 5, {A: 99}],
+      ['UpdateRecord', 'Data1', 3, {A: 95}],
+    ]));
+
+    // newRec behavior survives table renames.
+    await assert.isFulfilled(owner.applyUserActions(docId, [
+      ['UpdateRecord', 'Data1', 2, {A: 6}],
+      ['RenameTable', 'Data1', 'Data11'],
+      ['UpdateRecord', 'Data11', 2, {A: 7}],
+    ]));
+
+    // newRec behavior cannot at this time survive column renames.
+    await assert.isRejected(owner.applyUserActions(docId, [
+      ['UpdateRecord', 'Data11', 2, {A: 4}],
+      ['RenameColumn', 'Data11', 'B', 'BB'],
+      ['UpdateRecord', 'Data11', 2, {A: 5}],
+    ]), /Blocked by row update access rules/);
+  });
+
+  it('rules survive schema changes within a bundle', async function() {
+    // This is important because of renames, which propagate to ACL resources and rules.
+    // But then again, not that important since in-bundle changes are funky because of
+    // delayed formula updates.
+    await freshDoc();
+    await owner.applyUserActions(docId, [
+      ['AddTable', 'Data1', [{id: 'A', type: 'Numeric'},
+                             {id: 'B', type: 'Numeric'}]],
+      ['AddRecord', 'Data1', null, {A: 0, B: 0}],
+      ['AddTable', 'Data2', [{id: 'A', type: 'Numeric'}]],
+
+      ['AddRecord', '_grist_ACLResources', -1, {tableId: 'Data1', colIds: 'A'}],
+      ['AddRecord', '_grist_ACLResources', -2, {tableId: 'Data1', colIds: '*'}],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -1, aclFormula: 'rec.B > 0', permissionsText: '+U', memo: 'me I did it',
+      }],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -1, aclFormula: '', permissionsText: '-U',
+      }],
+    ]);
+    await assert.isFulfilled(owner.applyUserActions(docId, [
+      ['UpdateRecord', 'Data1', 1, {B: 1}],
+      ['UpdateRecord', 'Data1', 1, {A: 20}],
+      ['UpdateRecord', 'Data1', 1, {B: 2}],
+      ['RenameColumn', 'Data1', 'B', 'BB'],
+      ['RenameTable', 'Data1', 'Data11'],
+      ['UpdateRecord', 'Data11', 1, {A: 21}],
+      ['RenameColumn', 'Data11', 'BB', 'B'],
+      ['RenameTable', 'Data11', 'Data1'],
+    ]));
+    await assert.isRejected(owner.applyUserActions(docId, [
+      ['UpdateRecord', 'Data1', 1, {B: 1}],
+      ['UpdateRecord', 'Data1', 1, {A: 20}],
+      ['UpdateRecord', 'Data1', 1, {B: 0}],
+      ['RenameColumn', 'Data1', 'B', 'BB'],
+      ['RenameTable', 'Data1', 'Data11'],
+      ['UpdateRecord', 'Data11', 1, {A: 21}],
+      ['RenameColumn', 'Data11', 'BB', 'B'],
+      ['RenameTable', 'Data11', 'Data1'],
+    ]), /Blocked by .* access rules/);
+  });
+
+  it('can limit workflow', async function() {
+    await freshDoc();
+    // Make a table with a choice column containing PENDING, STARTED, and FINISHED, with
+    // only modification allowed to that column being to increment it.
+    await owner.applyUserActions(docId, [
+      ['AddTable', 'Data1', [{id: 'Status', type: 'Choice'},
+                             {id: 'StatusIndex', isFormula: true,
+                              formula: 'try:\n\treturn ["PENDING", "STARTED", "FINISHED"]' +
+                              '.index($Status)\nexcept:\n\treturn -1'}]],
+      ['AddRecord', 'Data1', null, {Status: 'PENDING'}],
+      ['AddRecord', 'Data1', null, {Status: 'STARTED'}],
+      ['AddRecord', 'Data1', null, {Status: 'FINISHED'}],
+
+      ['AddRecord', '_grist_ACLResources', -1, {tableId: 'Data1', colIds: 'Status'}],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -1, aclFormula: 'newRec.StatusIndex <= rec.StatusIndex', permissionsText: '-U',
+      }],
+    ]);
+    const api = owner.getDocAPI(docId);
+    // PENDING -> STARTED allowed.
+    await assert.isFulfilled(api.updateRows('Data1', { id: [1], Status: ['STARTED'] }));
+    // STARTED -> PENDING forbidden.
+    await assert.isRejected(api.updateRows('Data1', { id: [1], Status: ['PENDING'] }));
+    // STARTED -> FINISHED allowed.
+    await assert.isFulfilled(api.updateRows('Data1', { id: [1], Status: ['FINISHED'] }));
+    // FINISHED -> earlier state forbidden.
+    await assert.isRejected(api.updateRows('Data1', { id: [1], Status: ['STARTED'] }));
+    await assert.isRejected(api.updateRows('Data1', { id: [1], Status: ['PENDING'] }));
+    await assert.isRejected(api.updateRows('Data1', { id: [1], Status: ['...'] }));
+    // This next "change" succeeds because the user action is translated into a no-op
+    // by the data engine, and that no-op is permitted.
+    await assert.isFulfilled(api.updateRows('Data1', { id: [1], Status: ['FINISHED'] }));
+  });
+
+  it('respects user-private tables', async function() {
+    await freshDoc();
+
+    const editorProfile = await editor.getUserProfile();
+
+    // Make a Private table and mark it as user-only (using temporary representation).
+    // Make a Public table without any particular access control.
+    await owner.applyUserActions(docId, [
+      ['AddTable', 'Private', [{id: 'A'}]],
+      ['AddRecord', '_grist_ACLResources', -1, {tableId: 'Private', colIds: '*'}],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -1,
+        aclFormula: `user.UserID == ${editorProfile.id}`,
+        permissionsText: 'all',
+        memo: 'editor check',
+      }],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -1, aclFormula: '', permissionsText: 'none',
+      }],
+      ['AddTable', 'Public', [{id: 'A'}]],
+    ]);
+
+    // Owner can access only the public table.
+    await assertDeniedFor(owner.getDocAPI(docId).getRows('Private'), ['editor check']);
+    await assert.isFulfilled(owner.getDocAPI(docId).getRows('Public'));
+
+    // Editor can access both tables.
+    await assert.isFulfilled(editor.getDocAPI(docId).getRows('Private'));
+    await assert.isFulfilled(editor.getDocAPI(docId).getRows('Public'));
+
+    // There are a lot of things the owner can still do, because they are
+    // an owner - including downloading doc, changing access rules etc, editing
+    // the table.  But the table will be hidden in the client, making it difficult
+    // to accidentally edit/view through it at least.
+  });
+
+  it('allows characteristic tables', async function() {
+    await freshDoc();
+
+    const editorProfile = await editor.getUserProfile();
+
+    await owner.applyUserActions(docId, [
+      ['AddTable', 'Seattle', [{id: 'A'}]],
+      ['AddTable', 'Zones', [{id: 'Email'}, {id: 'City'}]],
+      ['AddRecord', 'Zones', null, {Email: editorProfile.email, City: 'Seattle'}],
+      ['AddRecord', '_grist_ACLResources', -1, {tableId: '*', colIds: '*'}],
+      ['AddRecord', '_grist_ACLResources', -2, {tableId: 'Seattle', colIds: '*'}],
+      ['AddRecord', '_grist_ACLResources', -3, {tableId: 'Zones', colIds: '*'}],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -1, userAttributes: JSON.stringify({
+          name: 'Zone',
+          tableId: 'Zones',
+          charId: 'Email',
+          lookupColId: 'Email',
+        })
+      }],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -2,
+        aclFormula: 'user.Zone.City != "Seattle"',
+        permissionsText: 'none',
+        memo: 'city check',
+      }],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -3,
+        aclFormula: 'user.Access != "owners"',
+        permissionsText: 'none',
+        memo: 'owner check',
+      }],
+    ]);
+
+    await assertDeniedFor(owner.getDocAPI(docId).getRows('Seattle'), ['city check']);
+    await assert.isFulfilled(owner.getDocAPI(docId).getRows('Zones'));
+
+    await assert.isFulfilled(editor.getDocAPI(docId).getRows('Seattle'));
+    await assertDeniedFor(editor.getDocAPI(docId).getRows('Zones'), ['owner check']);
+  });
+
+  it('allows characteristic tables to control row access', async function() {
+    await freshDoc();
+
+    const ownerProfile = await owner.getUserProfile();
+    const editorProfile = await editor.getUserProfile();
+
+    await owner.applyUserActions(docId, [
+      ['AddTable', 'Leads', [{id: 'Name'}, {id: 'Place'}]],
+      ['AddRecord', 'Leads', null, {Name: 'Yi Wen', Place: 'Seattle'}],
+      ['AddRecord', 'Leads', null, {Name: 'Zeng Hua', Place: 'Seattle'}],
+      ['AddRecord', 'Leads', null, {Name: 'Tao Ping', Place: 'Boston'}],
+      ['AddTable', 'Zones', [{id: 'Email'}, {id: 'City'}]],
+      ['AddRecord', 'Zones', null, {Email: editorProfile.email, City: 'Seattle'}],
+      ['AddRecord', 'Zones', null, {Email: ownerProfile.email, City: 'Boston'}],
+      ['AddRecord', '_grist_ACLResources', -1, {tableId: '*', colIds: '*'}],
+      ['AddRecord', '_grist_ACLResources', -2, {tableId: 'Leads', colIds: '*'}],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -1, userAttributes: JSON.stringify({
+          name: 'Zone',
+          tableId: 'Zones',
+          charId: 'Email',
+          lookupColId: 'Email',
+        })
+      }],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -2, aclFormula: 'user.Zone.City != rec.Place', permissionsText: 'none',
+      }],
+    ]);
+
+    // Editor sees Seattle rows.
+    assert.deepEqual((await editor.getDocAPI(docId).getRows('Leads')).id, [1, 2]);
+
+    // Owner sees Boston rows.
+    assert.deepEqual((await owner.getDocAPI(docId).getRows('Leads')).id, [3]);
+  });
+
+  it('respects column level access denial', async function() {
+    await freshDoc();
+
+    // Make a table with 4 columns, only 2 of which should be available to non-owners.
+    await owner.applyUserActions(docId, [
+      ['AddTable', 'Data1', [{id: 'A', type: 'Numeric'}, {id: 'B', type: 'Numeric'},
+                             {id: 'C', isFormula: true, formula: '$A + $B'},
+                             {id: 'D', isFormula: true, formula: '$A - $B'}]],
+      ['AddRecord', 'Data1', null, {A: 10, B: 4}],
+      ['AddRecord', 'Data1', null, {A: 20, B: 5}],
+      ['AddRecord', '_grist_ACLResources', -1, {tableId: 'Data1', colIds: 'A,C'}],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -1, aclFormula: 'user.Access != "owners"', permissionsText: 'none',
+      }],
+    ]);
+
+    const expect: TableColValues = {
+      id: [1, 2],
+      manualSort: [1, 2],
+      A: [10, 20],
+      B: [4, 5],
+      C: [14, 25],
+      D: [6, 15],
+    };
+    assert.deepEqual((await owner.getDocAPI(docId).getRows('Data1')), expect);
+    delete expect.A;
+    delete expect.C;
+    assert.deepEqual((await editor.getDocAPI(docId).getRows('Data1')), expect);
+  });
+
+  it('respects column level access granting', async function() {
+    await freshDoc();
+
+    // Make a table with 4 columns, only 2 of which should be available to non-owners.
+    // Flips previous test by defaulting to denying columns, then granting access to
+    // those we want to share (rather than denying individual columns we don't wish to
+    // share).
+    await owner.applyUserActions(docId, [
+      ['AddTable', 'Data1', [{id: 'A', type: 'Numeric'}, {id: 'B', type: 'Numeric'},
+                             {id: 'C', isFormula: true, formula: '$A + $B'},
+                             {id: 'D', isFormula: true, formula: '$A - $B'}]],
+      ['AddRecord', 'Data1', null, {A: 10, B: 4}],
+      ['AddRecord', 'Data1', null, {A: 20, B: 5}],
+      ['AddRecord', '_grist_ACLResources', -1, {tableId: 'Data1', colIds: '*'}],
+      ['AddRecord', '_grist_ACLResources', -2, {tableId: 'Data1', colIds: 'B,D'}],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -2, aclFormula: '', permissionsText: 'all',
+      }],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -1, aclFormula: 'user.Access != "owners"', permissionsText: 'none',
+      }],
+    ]);
+
+    const expect: TableColValues = {
+      id: [1, 2],
+      manualSort: [1, 2],
+      A: [10, 20],
+      B: [4, 5],
+      C: [14, 25],
+      D: [6, 15],
+    };
+    assert.deepEqual((await owner.getDocAPI(docId).getRows('Data1')), expect);
+    delete expect.A;
+    delete expect.C;
+    assert.deepEqual((await editor.getDocAPI(docId).getRows('Data1')), expect);
+  });
+
+  it('only respects read+update permissions in column-level rules', async function() {
+    // Seed rules previously could result in column-level rules that could contain create+delete
+    // permissions. Even if those appear in rules, we should ignore them.
+    await freshDoc();
+
+    // Create a table with columns A, B. Table denies access, but column A allows all. This
+    // situation used to be easy to get into with seed rules when they didn't trim permission bits.
+    await owner.applyUserActions(docId, [
+      ['AddTable', 'Data1', [{id: 'A', type: 'Numeric'}, {id: 'B', type: 'Numeric'}]],
+      ['AddRecord', 'Data1', null, {A: 10, B: 4}],
+      ['AddRecord', 'Data1', null, {A: 20, B: 5}],
+      ['AddRecord', '_grist_ACLResources', -1, {tableId: 'Data1', colIds: '*'}],
+      ['AddRecord', '_grist_ACLResources', -2, {tableId: 'Data1', colIds: 'A'}],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -2, aclFormula: '', permissionsText: '+CRUD',
+      }],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -1, aclFormula: '', permissionsText: '+R-UCD',
+      }],
+    ]);
+
+    // Check that we can fetch all data, no restrictions there.
+    assert.deepEqual((await owner.getDocAPI(docId).getRows('Data1')), {
+      id: [1, 2],
+      manualSort: [1, 2],
+      A: [10, 20],
+      B: [4, 5],
+    });
+
+    // Check that we cannot add or delete records (despite column rule seeming to allow it).
+    await assert.isRejected(owner.applyUserActions(docId, [
+      ["AddRecord", "Data1", null, {"A": 30}],
+    ]), /Blocked by table create access rules/);
+
+    await assert.isRejected(owner.applyUserActions(docId, [
+      ["RemoveRecord", "Data1", 2],
+    ]), /Blocked by table delete access rules/);
+
+    // The column rule does its job: allows update to column A.
+    await owner.applyUserActions(docId, [
+      ["UpdateRecord", "Data1", 2, {"A": 2000}]
+    ]);
+
+    // But the table rule applies to column B.
+    await assert.isRejected(owner.applyUserActions(docId, [
+      ["UpdateRecord", "Data1", 2, {"B": 500}],
+    ]), /Blocked by column update access rules/);
+
+    assert.deepEqual((await owner.getDocAPI(docId).getRows('Data1')), {
+      id: [1, 2],
+      manualSort: [1, 2],
+      A: [10, 2000],
+      B: [4, 5],
+    });
+  });
+
+  it('always allows Calculate action', async function() {
+    await freshDoc();
+
+    // Make a cell set to `=NOW()` and forbid updating it.
+    await owner.applyUserActions(docId, [
+      ['AddTable', 'Data1', [{id: 'Now', isFormula: true, formula: 'NOW()'}]],
+      ['AddRecord', 'Data1', null, {}],
+      ['AddRecord', '_grist_ACLResources', -1, {tableId: 'Data1', colIds: '*'}],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -1, aclFormula: '', permissionsText: '-U',
+      }],
+      ['AddTable', 'Private', [{id: 'A'}]],
+    ]);
+
+    const now1 = (await owner.getDocAPI(docId).getRows('Data1')).Now[0];
+    await owner.getDocAPI(docId).forceReload();
+    const now2 = (await owner.getDocAPI(docId).getRows('Data1')).Now[0];
+    assert.notDeepEqual(now1, now2);
+  });
+
+  it('can undo changes partially if all are not permitted', async function() {
+    await freshDoc();
+
+    await owner.applyUserActions(docId, [
+      ['AddTable', 'Data1', [{id: 'A', type: 'Int'},  // editor has full rights
+                             {id: 'B', type: 'Int'},  // editor can read only
+                             {id: 'C', type: 'Int'},  // editor can edit on some rows
+                             {id: 'D', type: 'Int'},  // editor can edit on some rows
+                             {id: 'E', type: 'Int'},  // editor cannot view or edit
+                             {id: 'F', isFormula: true, formula: '$A'}]],  // read only
+      ['AddRecord', 'Data1', null, {A: 10, B: 10, C: 10, D: 10, E: 10}], //  x  x
+      ['AddRecord', 'Data1', null, {A: 11, B: 11, C: 11, D: 11, E: 11}],
+      ['AddRecord', 'Data1', null, {A: 12, B: 12, C: 12, D: 12, E: 12}], //  x
+      ['AddRecord', 'Data1', null, {A: 13, B: 13, C: 13, D: 13, E: 13}], //     x
+      ['AddRecord', '_grist_ACLResources', -1, {tableId: 'Data1', colIds: '*'}],
+      ['AddRecord', '_grist_ACLResources', -2, {tableId: 'Data1', colIds: 'B'}],
+      ['AddRecord', '_grist_ACLResources', -3, {tableId: 'Data1', colIds: 'C'}],
+      ['AddRecord', '_grist_ACLResources', -4, {tableId: 'Data1', colIds: 'D'}],
+      ['AddRecord', '_grist_ACLResources', -5, {tableId: 'Data1', colIds: 'E'}],
+      ['AddRecord', '_grist_ACLResources', -6, {tableId: 'Data1', colIds: 'F'}],
+      ['AddRecord', '_grist_ACLRules', null, {
+        // editor can only create or delete rows with A odd.
+        resource: -1, aclFormula: 'user.Access != OWNER and rec.A % 2 == 1', permissionsText: '-CD',
+      }],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -2, aclFormula: 'user.Access != OWNER', permissionsText: '-U',
+      }],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -3, aclFormula: 'user.Access != OWNER and rec.id % 2 == 1', permissionsText: '-U',
+      }],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -4, aclFormula: 'user.Access != OWNER and rec.id % 3 == 1', permissionsText: '-U',
+      }],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -5, aclFormula: 'user.Access != OWNER', permissionsText: 'none',
+      }],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -6, aclFormula: 'user.Access != OWNER', permissionsText: '-U',
+      }],
+    ]);
+
+    // Share the document with everyone as an editor.
+    await owner.updateDocPermissions(docId, { users: { 'everyone@getgrist.com': 'editors' } });
+
+    // Check that a (fake) undo that affects only material user has edit rights on works.
+    const expected = await owner.getDocAPI(docId).getRows('Data1');
+    await applyAsUndo(cliEditor, [['UpdateRecord', 'Data1', 1, {A: 55}]]);
+    expected.A[0] = 55;
+    expected.F[0] = 55;
+    assert.deepEqual(await owner.getDocAPI(docId).getRows('Data1'), expected);
+
+    // Check that an undo that includes a change to a column the user cannot edit has that
+    // change stripped.
+    await applyAsUndo(cliEditor, [['UpdateRecord', 'Data1', 1, {A: 56, B: 99, E: 99}]]);
+    expected.A[0] = 56;
+    expected.F[0] = 56;
+    assert.deepEqual(await owner.getDocAPI(docId).getRows('Data1'), expected);
+
+    // Check that changes to specific cells the user cannot edit are also stripped.
+    await applyAsUndo(cliEditor, [['BulkUpdateRecord', 'Data1', [1, 2, 3, 4],
+                                   {A: [60, 71, 81, 90],
+                                    C: [100, 110, 120, 130],
+                                    D: [140, 150, 160, 170]}]]);
+    expected.F[0] = expected.A[0] = 60;
+    expected.F[1] = expected.A[1] = 71;
+    expected.F[2] = expected.A[2] = 81;
+    expected.F[3] = expected.A[3] = 90;
+    expected.C[1] = 110;
+    expected.C[3] = 130;
+    expected.D[1] = 150;
+    expected.D[2] = 160;
+    assert.deepEqual(await owner.getDocAPI(docId).getRows('Data1'), expected);
+
+    // Check that adds and removes work or are blocked as expected.
+    // Editor can only create/delete rows with A odd.
+    await applyAsUndo(cliEditor, [
+      ['AddRecord', 'Data1', 999, {A: 77}],   // should be skipped, A must be even
+      ['BulkRemoveRecord', 'Data1', [1, 2]],   // should skip rowId 2, A must be even
+    ]);
+    for (const key of Object.keys(expected)) {
+      // Only first row is removed; no addition.
+      pruneArray(expected[key], [0]);
+    }
+    assert.deepEqual(await owner.getDocAPI(docId).getRows('Data1'), expected);
+
+    await applyAsUndo(cliEditor, [
+      ['AddRecord', 'Data1', 1000, {A: 88}],   // should be allowed, A is even.
+      ['BulkAddRecord', 'Data1', [1001, 1002], {A: [90, 91]}], // first should be allowed
+    ]);
+    expected.id.push(1000, 1001);
+    expected.A.push(88, 90);
+    expected.B.push(0, 0);
+    expected.C.push(0, 0);
+    expected.D.push(0, 0);
+    expected.E.push(0, 0);
+    expected.F.push(88, 90);
+    expected.manualSort.push(null, null);  // perhaps in a real undo these would have been set in DocActions?
+    assert.deepEqual(await owner.getDocAPI(docId).getRows('Data1'), expected);
+  });
+
+  it('getAclResources exposes all tableIds and colIds to those with access rules access', async function() {
+    await freshDoc();
+    await owner.applyUserActions(docId, [
+      ['AddTable', 'Data1', [{id: 'A', type: 'Numeric'}, {id: 'B', type: 'Numeric'}]],
+      ['AddTable', 'Data2', [{id: 'C', type: 'Numeric'}, {id: 'D', type: 'Numeric'}]],
+      ['AddRecord', '_grist_ACLResources', -1, {tableId: 'Data1', colIds: 'A'}],
+      ['AddRecord', '_grist_ACLResources', -2, {tableId: 'Data2', colIds: '*'}],
+      // Nobody gets access.
+      ['AddRecord', '_grist_ACLRules', null, {resource: -1, aclFormula: '', permissionsText: 'none'}],
+      ['AddRecord', '_grist_ACLRules', null, {resource: -2, aclFormula: '', permissionsText: 'none'}],
+    ]);
+
+    // Check that the owner does not see the blocked resources normally.
+    const data1 = await owner.getDocAPI(docId).getRows('Data1');
+    assert.property(data1, 'B');
+    assert.notProperty(data1, 'A');
+    await assert.isRejected(owner.getDocAPI(docId).getRows('Data2'));
+
+    // But the owner sees them in getAclResources call. This call is available via the websocket.
+    assert.deepInclude((await cliOwner.send("getAclResources", 0)).data.tables, {
+      Data1: {
+        title: 'Data1',
+        colIds: ['id', 'manualSort', 'A', 'B'],
+        groupByColLabels: null
+      },
+      Data2: {
+        title: 'Data2',
+        colIds: ['id', 'manualSort', 'C', 'D'],
+        groupByColLabels: null
+      },
+    });
+
+    // Others can NOT call getAclResources.
+    assert.match((await cliEditor.send("getAclResources", 0)).error!, /Cannot list ACL resources/);
+
+    // Grant access to Access Rules.
+    await owner.applyUserActions(docId, [
+      ['AddRecord', '_grist_ACLResources', -1, {tableId: '*SPECIAL', colIds: 'AccessRules'}],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -1, aclFormula: 'user.Access == "editors"', permissionsText: '+R',
+      }],
+    ]);
+
+    // Now others CAN call getAclResources.
+    assert.deepInclude((await cliEditor.send("getAclResources", 0)).data.tables, {
+      Data1: {
+        title: 'Data1',
+        colIds: ['id', 'manualSort', 'A', 'B'],
+        groupByColLabels: null
+      },
+      Data2: {
+        title: 'Data2',
+        colIds: ['id', 'manualSort', 'C', 'D'],
+        groupByColLabels: null
+      },
+    });
+  });
+
+  it('allows column conversions in the presence of per-row rules', async function() {
+    await freshDoc();
+    const results = await owner.applyUserActions(docId, [
+      ['AddTable', 'Data1', [{id: 'A'}, {id: 'locked', type: 'Bool'}]],
+      ['AddColumn', 'Data1', 'B', {type: 'Text', isFormula: false}],
+      ['AddRecord', 'Data1', null, {A: 1, locked: true}],
+      ['AddRecord', 'Data1', null, {A: 2, locked: true}],
+      ['AddRecord', 'Data1', null, {A: 3, locked: false}],
+
+      ['AddRecord', '_grist_ACLResources', -1, {tableId: 'Data1', colIds: '*'}],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -1, aclFormula: 'rec.locked and user.Access != "owners"', permissionsText: '+R-CUD',
+      }],
+    ]);
+
+    // Get the metadata rowId of column B in table Data1.
+    const colRef = results.retValues[1].colRef;
+
+    // Cell changes in a column conversion will bypass access control.  If the user has the
+    // permissionn to change the schema, then the column conversion will be permitted.
+    // (this test used to be more elaborate before this was true).
+    await assert.isFulfilled(editor.applyUserActions(docId,
+       [['UpdateRecord', '_grist_Tables_column', colRef, {type: 'Numeric'}]]));
+  });
+
+  // Checks for a bug in filtering first row.
+  it('can filter out first row correctly', async function() {
+    await freshDoc();
+    await owner.applyUserActions(docId, [
+      ['AddTable', 'Data1', [{id: 'A', type: 'Numeric'},
+                             {id: 'B', type: 'Numeric'},
+                             {id: 'Sum', isFormula: true, formula: '$A + $A'}]],
+      ['AddRecord', 'Data1', null, {A: 100, B: 50}],
+      ['AddRecord', 'Data1', null, {A: 200, B: 150}],
+      ['AddRecord', 'Data1', null, {A: 300, B: 250}],
+
+      ['AddRecord', '_grist_ACLResources', -1, {tableId: 'Data1', colIds: '*'}],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -1, aclFormula: 'user.Access != "owners" and rec.A != 7', permissionsText: '-R',
+      }],
+    ]);
+    cliOwner.flush();
+    cliEditor.flush();
+
+    // Change formula, which changes data in all rows, which then all need filtering out.
+    await owner.applyUserActions(docId, [
+      ['ModifyColumn', 'Data1', 'Sum', {formula: '$A + $B'}]
+    ]);
+    let fullResult = await cliOwner.readDocUserAction();
+    let filteredResult = await cliEditor.readDocUserAction();
+    assert.lengthOf(fullResult, 3);
+    assert.lengthOf(filteredResult, 2);
+    assert.deepEqual(fullResult.slice(0, 2), filteredResult);
+    assert.deepEqual(fullResult[2].slice(0, 2), ['BulkUpdateRecord', 'Data1']);
+
+    // Flip on a row to make sure it shows up.
+    await owner.applyUserActions(docId, [
+      ['UpdateRecord', 'Data1', 3, {A: 7}]
+    ]);
+    fullResult = await cliOwner.readDocUserAction();
+    filteredResult = await cliEditor.readDocUserAction();
+    assert.deepEqual(fullResult, [
+      [ 'UpdateRecord', 'Data1', 3, { A: 7 } ],
+      [ 'UpdateRecord', 'Data1', 3, { Sum: 257 } ]
+    ]);
+    assert.deepEqual(filteredResult, [
+      [ 'BulkAddRecord', 'Data1', [3], { manualSort: [3], A: [7], B: [250], Sum: [550] } ],
+      [ 'UpdateRecord', 'Data1', 3, { Sum: 257 } ]
+    ]);
+
+    // Flip on first row to make sure it shows up.
+    await owner.applyUserActions(docId, [
+      ['UpdateRecord', 'Data1', 1, {A: 7}]
+    ]);
+    fullResult = await cliOwner.readDocUserAction();
+    filteredResult = await cliEditor.readDocUserAction();
+    assert.deepEqual(fullResult, [
+      [ 'UpdateRecord', 'Data1', 1, { A: 7 } ],
+      [ 'UpdateRecord', 'Data1', 1, { Sum: 57 } ]
+    ]);
+    assert.deepEqual(filteredResult, [
+      [ 'BulkAddRecord', 'Data1', [1], { manualSort: [1], A: [7], B: [50], Sum: [150] } ],
+      [ 'UpdateRecord', 'Data1', 1, { Sum: 57 } ]
+    ]);
+  });
+
+  for (const first of ['editor', 'owner', 'any'] as const) {
+    it(`can censor specific cells in a column (${first} first)`, async function() {
+      if (first !== 'any') {
+        sandbox.stub(DocClientsDeps, 'BROADCAST_ORDER').value('series');
+      }
+
+      // Create some column rules that control read permission based on other columns.
+      // Add a rule that controls overall row read permission to check it interacts ok.
+      await freshDoc();
+      await owner.applyUserActions(docId, [
+        ['AddTable', 'Data1', [{id: 'A', type: 'Numeric'},
+                               {id: 'B', type: 'Numeric'},
+                               {id: 'C', type: 'Numeric'},
+                               {id: 'D', type: 'Numeric'}]],
+        ['AddRecord', 'Data1', null, {A: 100, B: 1, C: 40, D: 300}],
+        ['AddRecord', 'Data1', null, {A: 200, B: 2, C: 45, D: 200}],
+        ['AddRecord', 'Data1', null, {A: 300, B: 3, C: 50, D: 100}],
+
+        ['AddRecord', '_grist_ACLResources', -1, {tableId: 'Data1', colIds: 'C,D'}],
+        ['AddRecord', '_grist_ACLResources', -2, {tableId: 'Data1', colIds: 'B'}],
+        ['AddRecord', '_grist_ACLResources', -3, {tableId: 'Data1', colIds: '*'}],
+        ['AddRecord', '_grist_ACLRules', null, {
+          resource: -1, aclFormula: 'user.Access != "owners" and rec.A < 200', permissionsText: '-R',
+        }],
+        ['AddRecord', '_grist_ACLRules', null, {
+          resource: -2, aclFormula: 'user.Access != "owners" and rec.A < 50', permissionsText: '-R',
+        }],
+        ['AddRecord', '_grist_ACLRules', null, {
+          resource: -3, aclFormula: 'user.Access != "owners" and rec.B == 99', permissionsText: '-R',
+        }],
+      ]);
+      await reopenClients({first});
+
+      // Make a series of adds/updates, and make sure cells that are affected indirectly
+      // are censored or uncensored as appropriate.
+      cliEditor.flush();
+      cliOwner.flush();
+      await owner.getDocAPI(docId).addRows('Data1', {A: [300, 150], B: [1, 1], C: [1, 1], D: [1, 1]});
+      assert.deepEqual(await cliEditor.readDocUserAction(),
+                       [ [ 'BulkAddRecord',
+                           'Data1',
+                           [4, 5],
+                           { A: [300, 150], manualSort: [4, 5], B: [1, 1],
+                             C: [1, [GristObjCode.Censored]],
+                             D: [1, [GristObjCode.Censored]] } ] ]);
+      assert.deepEqual(await cliOwner.readDocUserAction(),
+                       [ [ 'BulkAddRecord',
+                           'Data1',
+                           [4, 5],
+                           { A: [300, 150], manualSort: [4, 5], B: [1, 1], C: [1, 1], D: [1, 1] } ] ]);
+      cliEditor.flush();
+      cliOwner.flush();
+      await owner.getDocAPI(docId).updateRows('Data1', {id: [4], A: [100]});
+      assert.deepEqual(await cliEditor.readDocUserAction(),
+                       [ [ 'UpdateRecord', 'Data1', 4, { A: 100 } ],
+                         [ 'BulkUpdateRecord', 'Data1', [ 4 ], { C: [ [GristObjCode.Censored] ] } ],
+                         [ 'BulkUpdateRecord', 'Data1', [ 4 ], { D: [ [GristObjCode.Censored] ] } ] ]);
+      assert.deepEqual(await cliOwner.readDocUserAction(),
+                       [ [ 'UpdateRecord', 'Data1', 4, { A: 100 } ] ]);
+      cliEditor.flush();
+      cliOwner.flush();
+      await owner.getDocAPI(docId).updateRows('Data1', {id: [4], A: [600]});
+      assert.deepEqual(await cliEditor.readDocUserAction(),
+                       [ [ 'UpdateRecord', 'Data1', 4, { A: 600 } ],
+                         [ 'BulkUpdateRecord', 'Data1', [ 4 ], { C: [ 1 ] } ],
+                         [ 'BulkUpdateRecord', 'Data1', [ 4 ], { D: [ 1 ] } ] ]);
+      assert.deepEqual(await cliOwner.readDocUserAction(),
+                       [ [ 'UpdateRecord', 'Data1', 4, { A:600 } ] ]);
+      cliEditor.flush();
+      cliOwner.flush();
+      await owner.getDocAPI(docId).updateRows('Data1', {id: [4], A: [3]});
+      assert.deepEqual(await cliEditor.readDocUserAction(),
+                       [ [ 'UpdateRecord', 'Data1', 4, { A: 3 } ],
+                         [ 'BulkUpdateRecord', 'Data1', [ 4 ], { B: [ [GristObjCode.Censored] ] } ],
+                         [ 'BulkUpdateRecord', 'Data1', [ 4 ], { C: [ [GristObjCode.Censored] ] } ],
+                         [ 'BulkUpdateRecord', 'Data1', [ 4 ], { D: [ [GristObjCode.Censored] ] } ] ]);
+      assert.deepEqual(await cliOwner.readDocUserAction(),
+                       [ [ 'UpdateRecord', 'Data1', 4, { A: 3 } ] ]);
+      cliEditor.flush();
+      cliOwner.flush();
+      await owner.getDocAPI(docId).updateRows('Data1', {id: [4], A: [75]});
+      assert.deepEqual(await cliEditor.readDocUserAction(),
+                       [ [ 'UpdateRecord', 'Data1', 4, { A: 75 } ],
+                         [ 'BulkUpdateRecord', 'Data1', [ 4 ], { B: [ 1 ] } ] ]);
+      assert.deepEqual(await cliOwner.readDocUserAction(),
+                       [ [ 'UpdateRecord', 'Data1', 4, { A: 75 } ] ]);
+      cliEditor.flush();
+      cliOwner.flush();
+      await owner.getDocAPI(docId).updateRows('Data1', {id: [4], B: [99]});
+      assert.deepEqual(await cliEditor.readDocUserAction(),
+                       [ [ 'BulkRemoveRecord', 'Data1', [ 4 ] ] ]);
+      assert.deepEqual(await cliOwner.readDocUserAction(),
+                       [ [ 'UpdateRecord', 'Data1', 4, { B: 99 } ] ]);
+      cliEditor.flush();
+      cliOwner.flush();
+      await owner.getDocAPI(docId).updateRows('Data1', {id: [4], B: [98]});
+      assert.deepEqual(await cliEditor.readDocUserAction(),
+                       [ [ 'BulkAddRecord',
+                           'Data1',
+                           [ 4 ],
+                           { manualSort: [ 4 ],
+                             A: [ 75 ],
+                             B: [ 98 ],
+                             C: [ [GristObjCode.Censored] ],
+                             D: [ [GristObjCode.Censored] ] } ] ]);
+      assert.deepEqual(await cliOwner.readDocUserAction(),
+                       [ [ 'UpdateRecord', 'Data1', 4, { B: 98 } ] ]);
+      cliEditor.flush();
+      cliOwner.flush();
+      await owner.getDocAPI(docId).updateRows('Data1', {id: [1, 2, 4], A: [1, 75, 200]});
+      assert.deepEqual(await cliEditor.readDocUserAction(),
+                       [ [ 'BulkUpdateRecord',
+                           'Data1',
+                           [ 1, 2, 4 ],
+                           { A: [ 1, 75, 200 ] } ],
+                         [ 'BulkUpdateRecord', 'Data1', [ 1 ], { B: [ [GristObjCode.Censored] ] } ],
+                         [ 'BulkUpdateRecord',
+                           'Data1',
+                           [ 2, 4 ],
+                           { C: [ [GristObjCode.Censored], 1 ] } ],
+                         [ 'BulkUpdateRecord',
+                           'Data1',
+                           [ 2, 4 ],
+                           { D: [ [GristObjCode.Censored], 1 ] } ] ]);
+      assert.deepEqual(await cliOwner.readDocUserAction(),
+                       [ [ 'BulkUpdateRecord',
+                           'Data1',
+                           [ 1, 2, 4 ],
+                           { A: [ 1, 75, 200 ] } ] ]);
+
+      // Add a formula column to simulate a reported bug (not actually needed to tickle problem)
+      // where a censored cell for one user could show up as censored for another.
+      await owner.applyUserActions(docId, [
+        ['AddColumn', 'Data1', 'E', {formula: '$C'}],
+        ['AddRecord', '_grist_ACLResources', -1, {tableId: 'Data1', colIds: 'E'}],
+        ['AddRecord', '_grist_ACLRules', null, {
+          resource: -1, aclFormula: 'user.Access != "owners" and rec.A < 200', permissionsText: '-R',
+        }],
+      ]);
+      cliEditor.flush();
+      cliOwner.flush();
+
+      await editor.getDocAPI(docId).updateRows('Data1', {id: [2], C: [999]});
+      assert.deepEqual(await cliEditor.readDocUserAction(),
+                       [ [ 'UpdateRecord', 'Data1', 2, { C: [GristObjCode.Censored] } ],
+                         [ 'UpdateRecord', 'Data1', 2, { E: [GristObjCode.Censored] } ] ]);
+      assert.deepEqual(await cliOwner.readDocUserAction(),
+                       [ [ 'UpdateRecord', 'Data1', 2, { C: 999 } ],
+                         [ 'UpdateRecord', 'Data1', 2, { E: 999 } ] ]);
+
+      // Check that only the owner can evaluate the formula.
+      let response = await cliOwner.send('getFormulaError', 0, 'Data1', 'E', 2);
+      assert.equal(response.data, 999);
+      response = await cliEditor.send('getFormulaError', 0, 'Data1', 'E', 2);
+      assert.equal(response.data, undefined);
+      assert.equal(response.error, 'Cannot access cell');
+      assert.equal(response.errorCode, 'ACL_DENY');
+    });
+  }
+
+  describe('filterColValues', async function() {
+    // A method for checking if a cell contains 'x'.
+    function xRemove(val: any) { return val === 'x'; }
+
+    for (const actType of ['BulkUpdateRecord', 'BulkAddRecord', 'ReplaceTableData', 'TableData'] as const) {
+      it(`should remove correct elements for ${actType}`, function() {
+        // Prepare a 1 row bulk action.
+        const action1: BulkUpdateRecord|BulkAddRecord|ReplaceTableData|TableDataAction = [
+          actType,
+          'Table1',
+          [1],
+          {
+            a: ['x'], b: ['b'], c: ['x']
+          }
+        ];
+        // Check the action is unchanged if row is not specified for filtering.
+        assert.deepEqual(filterColValues(cloneDeep(action1), (idx) => idx === 99, xRemove),
+                         [action1]);
+        // Check the action is filtered as expected if row is specified.  Action set returned
+        // is suboptimal, but nevertheless as expected.
+        assert.deepEqual(filterColValues(cloneDeep(action1), (idx) => idx === 0, xRemove),
+                         [[actType, 'Table1', [], {a: [], b: [], c: []}],
+                          [actType, 'Table1', [1], {b: ['b']}]]);
+        // Prepare a multi-row bulk action.
+        const action2: typeof action1 = [
+          actType,
+          'Table1',
+          [1, 2, 3],
+          {
+            a: ['x', 'a', 'a'], b: ['b', 'b', 'b'], c: ['x', 'c', 'x']
+          }
+        ];
+        // Check filtering is as expected: one retained row, two new actions for the
+        // two new permutations of columns.
+        assert.deepEqual(filterColValues(cloneDeep(action2), (idx) => idx % 2 === 0, xRemove),
+                         [[actType, 'Table1', [2], {a: ['a'], b: ['b'], c: ['c']}],
+                          [actType, 'Table1', [3], {a: ['a'], b: ['b']}],
+                          [actType, 'Table1', [1], {b: ['b']}]]);
+        // Prepare a many-row bulk action, and check filtering is as expected.
+        const action3: typeof action1 = [
+          actType,
+          'Table1',
+          [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
+          {
+            a: ['a', 'a', 'a', 'a', 'x', 'x', 'x', 'x', 'A', 'A', 'A', 'A'],
+            b: ['b', 'b', 'x', 'x', 'b', 'b', 'x', 'x', 'B', 'B', 'x', 'x'],
+            c: ['c', 'x', 'c', 'x', 'c', 'x', 'c', 'x', 'C', 'x', 'C', 'x'],
+          }
+        ];
+        assert.deepEqual(filterColValues(cloneDeep(action3), (idx) => ![0, 8].includes(idx), xRemove),
+                         [[actType, 'Table1', [1, 9], {a: ['a', 'A'], b: ['b', 'B'], c: ['c', 'C']}],
+                          [actType, 'Table1', [8], {}],
+                          [actType, 'Table1', [4, 12], {a: ['a', 'A']}],
+                          [actType, 'Table1', [2, 10], {a: ['a', 'A'], b: ['b', 'B']}],
+                          [actType, 'Table1', [3, 11], {a: ['a', 'A'], c: ['c', 'C']}],
+                          [actType, 'Table1', [6], {b: ['b']}],
+                          [actType, 'Table1', [5], {b: ['b'], c: ['c']}],
+                          [actType, 'Table1', [7], {c: ['c']}]]);
+      });
+    }
+
+    for (const actType of ['UpdateRecord', 'AddRecord'] as const) {
+      it(`should remove correct elements for ${actType}`, function() {
+        const action1: UpdateRecord|AddRecord = [
+          actType,
+          'Table1',
+          1,
+          {
+            a: 'x', b: 'b', c: 'x'
+          }
+        ];
+        assert.deepEqual(filterColValues(cloneDeep(action1), (idx) => idx === 0, xRemove),
+                         [[actType, 'Table1', 1, {b: 'b'}]]);
+        // shouldFilterRow is somewhat arbitrarily ignored for non-bulk changes.
+        assert.deepEqual(filterColValues(cloneDeep(action1), (idx) => idx === 99, xRemove),
+                         [[actType, 'Table1', 1, {b: 'b'}]]);
+      });
+    }
+
+    it('should not remove anything for BulkRemoveRecord', function() {
+      const action1: BulkRemoveRecord = ['BulkRemoveRecord', 'Table1', [1, 2, 3]];
+      assert.deepEqual(filterColValues(cloneDeep(action1), (idx) => idx === 0, xRemove), [action1]);
+    });
+
+    it('should not remove anything for RemoveRecord', function() {
+      const action1: RemoveRecord = ['RemoveRecord', 'Table1', 1];
+      assert.deepEqual(filterColValues(cloneDeep(action1), (idx) => idx === 0, xRemove), [action1]);
+    });
+  });
+
+  it('respects exceptional sessions for reading', async function() {
+    const activeDoc = await docTools.createDoc('test-doc');
+    // Make an exceptional session with full unconditional access.
+    const systemSession = makeExceptionalDocSession('system');
+    // Make a fake regular session with access-rule-dependent access.
+    const userSession = {
+      client: null,
+      req: {
+        docAuth: {
+          access: 'viewers',
+        },
+        user: {id: 1, logins: [{displayEmail: 'someone@getgrist.com'}]},
+        get: () => null,
+      } as any
+    };
+    // Deny everyone access to Table1, and a column and row of Table2.
+    await activeDoc.applyUserActions(systemSession, [
+      ['AddTable', 'Table1', [{id: 'A'}, {id: 'B'}]],
+      ['AddRecord', 'Table1', null, {A: 2021, B: 'kangaroo'}],
+      ['AddTable', 'Table2', [{id: 'A'}, {id: 'B'}]],
+      ['AddRecord', 'Table2', null, {A: 2022, B: 'wallaby'}],
+      ['AddRecord', 'Table2', null, {A: -1, B: 'koala'}],
+      ['AddRecord', '_grist_ACLResources', -1, {tableId: 'Table1', colIds: '*'}],
+      ['AddRecord', '_grist_ACLResources', -2, {tableId: 'Table2', colIds: 'B'}],
+      ['AddRecord', '_grist_ACLResources', -3, {tableId: 'Table2', colIds: '*'}],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -1, aclFormula: 'True', permissionsText: 'none',
+      }],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -2, aclFormula: 'True', permissionsText: 'none',
+      }],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -3, aclFormula: 'rec.A < 0', permissionsText: 'none',
+      }],
+    ]);
+    // Check that exceptional session has full access to Table1 anyway.
+    assert.deepEqual((await activeDoc.fetchTable(systemSession, 'Table1')).tableData,
+                     [ 'TableData', 'Table1', [ 1 ],
+                       { manualSort: [ 1 ], A: [ '2021' ], B: [ 'kangaroo' ] } ]);
+    // Check that regular session does not have access to Table1.
+    await assert.isRejected(activeDoc.fetchTable(userSession, 'Table1'),
+                            /Blocked by table read access rules/);
+    // Check that exceptional session has full access to Table2 anyway.
+    assert.deepEqual((await activeDoc.fetchTable(systemSession, 'Table2')).tableData,
+                     [ 'TableData', 'Table2', [ 1, 2 ],
+                       { manualSort: [ 1, 2 ], A: [ '2022', '-1' ], B: [ 'wallaby', 'koala' ] } ]);
+    // Check that regular session does not have full access to Table2.
+    assert.deepEqual((await activeDoc.fetchTable(userSession, 'Table2')).tableData,
+                     [ 'TableData', 'Table2', [ 1 ],
+                       { manualSort: [ 1 ], A: [ '2022' ] } ]);
+  });
+
+  for (const flags of ['-R', '-RS']) {
+    it(`can receive metadata updates even if there is a default ${flags} rule`, async function() {
+    await freshDoc();
+      // Make a document with a default rule forbidding editor from reading anything.
+      await owner.applyUserActions(docId, [
+        ['AddTable', 'Private', [{id: 'A'}]],
+        ['AddRecord', '_grist_ACLResources', -1, {tableId: '*', colIds: '*'}],
+        ['AddRecord', '_grist_ACLRules', null, {
+          resource: -1, aclFormula: `user.Access != OWNER`, permissionsText: flags,
+        }],
+      ]);
+
+      // Add an extra table, and capture the update sent to the editor.
+      cliEditor.flush();
+      await owner.applyUserActions(docId, [
+        ['AddTable', 'Private2', [{id: 'A'}]],
+      ]);
+      const msg = await cliEditor.readMessage();
+
+      // Make sure we saw something.
+      assert.isAbove(msg?.data?.docActions.length, 10);
+      // Make sure everything we saw was metadata, and the Private2 AddTable
+      // action itself did not slip through.
+      assert.equal((msg?.data?.docActions as Array<DocAction>)
+                     .every(a => a[1].startsWith('_grist')), true);
+    });
+  }
+
+  it('can enumerate and use "View As" users', async function() {
+    await freshDoc();
+
+    // Check that "View As" users cover users the document is shared with, and
+    // example users.
+    cliOwner.flush();
+    let perm: PermissionDataWithExtraUsers = (await cliOwner.send("getUsersForViewAs", 0)).data;
+    const getId = (name: string) => home.dbManager.testGetId(name) as Promise<number>;
+    const getRef = (email: string) => home.dbManager.getUserByLogin(email).then(user => user!.ref);
+    assert.deepEqual(perm.users, [
+      { id: await getId('Chimpy'), email: 'chimpy@getgrist.com', name: 'Chimpy',
+        ref: await getRef('chimpy@getgrist.com'),
+        picture: null, access: 'owners', isMember: true },
+      { id: await getId('Kiwi'), email: 'kiwi@getgrist.com', name: 'Kiwi',
+        ref: await getRef('kiwi@getgrist.com'),
+        picture: null, access: 'owners', isMember: false },
+      { id: await getId('Charon'), email: 'charon@getgrist.com', name: 'Charon',
+        ref: await getRef('charon@getgrist.com'),
+        picture: null, access: 'editors', isMember: false },
+    ]);
+    assert.deepEqual(perm.attributeTableUsers, []);
+    assert.deepEqual(perm.exampleUsers[0],
+                     { id: 0, email: 'owner@example.com', name: 'Owner', access: 'owners' });
+
+    // Add a user attribute table mentioning some users the doc is shared with and
+    // some novel users.
+    await owner.applyUserActions(docId, [
+      ['AddTable', 'Leads', [{id: 'Name'}, {id: 'Place'}]],
+      ['AddRecord', 'Leads', null, {Name: 'Yi Wen', Place: 'Seattle'}],
+      ['AddRecord', 'Leads', null, {Name: 'Zeng Hua', Place: 'Boston'}],
+      ['AddRecord', 'Leads', null, {Name: 'Tao Ping', Place: 'Cambridge'}],
+      ['AddTable', 'Zones', [{id: 'Email'}, {id: 'City'}]],
+      ['AddRecord', 'Zones', null, {Email: 'chimpy@getgrist.com', City: 'Seattle'}],
+      ['AddRecord', 'Zones', null, {Email: 'charon@getgrist.com', City: 'Boston'}],
+      ['AddRecord', 'Zones', null, {Email: 'fast@speed.com', City: 'Cambridge'}],
+      ['AddRecord', 'Zones', null, {Email: 'slow@speed.com', City: 'Springfield'}],
+      ['AddRecord', '_grist_ACLResources', -1, {tableId: '*', colIds: '*'}],
+      ['AddRecord', '_grist_ACLResources', -2, {tableId: 'Leads', colIds: '*'}],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -1, userAttributes: JSON.stringify({
+          name: 'Zone',
+          tableId: 'Zones',
+          charId: 'Email',
+          lookupColId: 'Email',
+        }),
+      }],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -2, aclFormula: 'user.Zone.City and user.Zone.City != rec.Place', permissionsText: 'none',
+      }],
+    ]);
+
+    // Check that "View As" users now in addition have the novel user attribute table
+    // users.
+    cliOwner.flush();
+    perm = (await cliOwner.send("getUsersForViewAs", 0)).data;
+    assert.deepEqual(perm.users, [
+      { id: await getId('Chimpy'), email: 'chimpy@getgrist.com', name: 'Chimpy',
+        ref: await getRef('chimpy@getgrist.com'),
+        picture: null, access: 'owners', isMember: true },
+      { id: await getId('Kiwi'), email: 'kiwi@getgrist.com', name: 'Kiwi',
+        ref: await getRef('kiwi@getgrist.com'),
+        picture: null, access: 'owners', isMember: false },
+      { id: await getId('Charon'), email: 'charon@getgrist.com', name: 'Charon',
+        ref: await getRef('charon@getgrist.com'),
+        picture: null, access: 'editors', isMember: false },
+    ]);
+    assert.deepEqual(perm.attributeTableUsers, [
+      { id: 0, email: 'fast@speed.com', name: 'fast', access: 'editors' },
+      { id: 0, email: 'slow@speed.com', name: 'slow', access: 'editors' },
+    ]);
+    assert.deepEqual(perm.exampleUsers[0],
+                     { id: 0, email: 'owner@example.com', name: 'Owner', access: 'owners' });
+
+    // Add a second user attribute table, this time also with names and access levels.
+    await owner.applyUserActions(docId, [
+      ['AddTable', 'Users', [{id: 'Email2'}, {id: 'Name'}, {id: 'Access'}]],
+      ['AddRecord', 'Users', null, {Email2: 'red@color.com', Name: 'Rita', Access: 'owners'}],
+      ['AddRecord', 'Users', null, {Email2: 'green@color.com', Name: 'Gary', Access: 'editors'}],
+      ['AddRecord', 'Users', null, {Email2: 'blue@color.com', Name: 'Beatrix', Access: 'viewers'}],
+      ['AddRecord', 'Users', null, {Email2: 'yellow@color.com', Name: 'Yan', Access: null}],
+      ['AddRecord', '_grist_ACLResources', -1, {tableId: '*', colIds: '*'}],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -1, userAttributes: JSON.stringify({
+          name: 'More',
+          tableId: 'Users',
+          charId: 'Email',
+          lookupColId: 'Email2',
+        })
+      }],
+    ]);
+
+    // Check the new users get added as "View As" options.
+    cliOwner.flush();
+    perm = (await cliOwner.send("getUsersForViewAs", 0)).data;
+    assert.deepEqual(perm.attributeTableUsers, [
+      { id: 0, email: 'fast@speed.com', name: 'fast', access: 'editors' },
+      { id: 0, email: 'slow@speed.com', name: 'slow', access: 'editors' },
+      { id: 0, email: 'red@color.com', name: 'Rita', access: 'owners' },
+      { id: 0, email: 'green@color.com', name: 'Gary', access: 'editors' },
+      { id: 0, email: 'blue@color.com', name: 'Beatrix', access: 'viewers' },
+      { id: 0, email: 'yellow@color.com', name: 'Yan', access: null },
+    ]);
+
+    // Check that doing a "View As" as a user from the first user attribute table works
+    // as expected (the user is an editor, and has the expected user attributes in rules).
+    await reopenClients({linkParameters: {aclAsUser: 'fast@speed.com'}});
+    cliOwner.flush();
+    assert.deepEqual((await cliOwner.send('fetchTable', 0, 'Leads')).data.tableData,
+                     [ 'TableData', 'Leads', [ 3 ],
+                       { manualSort: [ 3 ], Place: [ 'Cambridge' ], Name: [ 'Tao Ping' ] } ]);
+    assert.deepEqual((await cliOwner.send('applyUserActions', 0,
+                                          [['UpdateRecord', 'Leads', 3, {Name: 'Tao'}]])).data,
+                     { actionNum: 4, retValues: [ null ], isModification: true });
+    assert.match((await cliOwner.send('applyUserActions', 0,
+                                     [['UpdateRecord', 'Leads', 2, {Name: 'Zao'}]])).error!,
+                 /Blocked by row update access rules/);
+
+    // Check that doing a "View As" as a user from the second user attribute table works
+    // as expected (the user has the specified access level, "viewers" in this case).
+    await reopenClients({linkParameters: {aclAsUser: 'blue@color.com'}});
+    cliOwner.flush();
+    assert.deepEqual((await cliOwner.send('fetchTable', 0, 'Leads')).data.tableData,
+                     [ 'TableData', 'Leads', [ 1, 2, 3 ],
+                       { manualSort: [ 1, 2, 3 ],
+                         Place: [ 'Seattle', 'Boston', 'Cambridge' ],
+                         Name: [ 'Yi Wen', 'Zeng Hua', 'Tao' ] } ]);
+    assert.match((await cliOwner.send('applyUserActions', 0,
+                                      [['UpdateRecord', 'Leads', 2, {Name: 'Zao'}]])).error!,
+                 /Blocked by table update access rules/);
+
+    // Check that doing a "View As" as a dummy user works as expected.
+    await reopenClients({linkParameters: {aclAsUser: 'viewer@example.com'}});
+    cliOwner.flush();
+    assert.match((await cliOwner.send('applyUserActions', 0,
+                                      [['UpdateRecord', 'Leads', 2, {Name: 'Zao'}]])).error!,
+                 /Blocked by table update access rules/);
+    await reopenClients({linkParameters: {aclAsUser: 'owner@example.com'}});
+    cliOwner.flush();
+    assert.deepEqual((await cliOwner.send('applyUserActions', 0,
+                                          [['UpdateRecord', 'Leads', 2, {Name: 'Zao'}]])).data,
+                     { actionNum: 5, retValues: [ null ], isModification: true });
+    await reopenClients({linkParameters: {aclAsUser: 'unknown@example.com'}});
+    cliOwner.flush();
+    assert.match((await cliOwner.send('applyUserActions', 0,
+                                     [['UpdateRecord', 'Leads', 2, {Name: 'Gao'}]])).error!,
+                 /Blocked by table update access rules/);
+    assert.match((await cliOwner.send('fetchTable', 0, 'Leads')).error!,
+                 /Blocked by table read access rules/);
+
+    // Check that doing a "View As" a user the doc is shared with works as expected.
+    await reopenClients({linkParameters: {aclAsUser: 'charon@getgrist.com'}});
+    cliOwner.flush();
+    assert.deepEqual((await cliOwner.send('fetchTable', 0, 'Leads')).data.tableData,
+                     [ 'TableData', 'Leads', [ 2 ],
+                       { manualSort: [ 2 ], Place: [ 'Boston' ], Name: [ 'Zao' ] } ]);
+
+    // Check that doing a "View As" an unknown user works reasonably
+    await reopenClients({linkParameters: {aclAsUser: 'mystery@getgrist.com'}});
+    cliOwner.flush();
+    assert.match((await cliOwner.send('fetchTable', 0, 'Leads')).error!,
+                 /Blocked by table read access rules/);
+  });
+
+  it('controls read and write access to attachment content', async function() {
+    await freshDoc();
+
+    // Make a table, with attachments, and with non-owners missing access to a row.
+    await owner.applyUserActions(docId, [
+      ['AddTable', 'Data1', [{id: 'A'},
+                             {id: 'B'},
+                             {id: 'Pics', type: 'Attachments'},
+                             {id: 'Public', isFormula: true, formula: '$B == "clear"'}]],
+      ['AddRecord', 'Data1', null, {A: 'near', B: 'clear'}],
+      ['AddRecord', 'Data1', null, {A: 'far', B: 'notclear'}],
+      ['AddRecord', 'Data1', null, {A: 'in a motor car', B: 'clear'}],
+      ['AddRecord', '_grist_ACLResources', -1, {tableId: 'Data1', colIds: '*'}],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -1, aclFormula: 'user.Access != OWNER and not rec.Public', permissionsText: 'none',
+      }],
+    ]);
+
+    // Add some attachments.
+    const i1 = await owner.getDocAPI(docId).uploadAttachment('data1', '1.png');
+    const i2 = await owner.getDocAPI(docId).uploadAttachment('data2', '2.png');
+    const i3 = await owner.getDocAPI(docId).uploadAttachment('data3', '3.png');
+    const i4 = await owner.getDocAPI(docId).uploadAttachment('data4', '4.png');
+    await owner.getDocAPI(docId).updateRows('Data1', {id: [1], Pics: [[GristObjCode.List, i1, i2]]});
+    await owner.getDocAPI(docId).updateRows('Data1', {id: [2], Pics: [[GristObjCode.List, i3]]});
+    await owner.getDocAPI(docId).updateRows('Data1', {id: [3], Pics: [[GristObjCode.List, i4]]});
+
+     // Share the document with everyone as an editor.
+    await owner.updateDocPermissions(docId, { users: { 'everyone@getgrist.com': 'editors' } });
+
+    // Check an editor can only access the attachments we expect.
+    assert.equal(await getAttachment(editor, docId, i1), 'data1');
+    assert.equal(await getAttachment(editor, docId, i2), 'data2');
+    await assert.isRejected(getAttachment(editor, docId, i3), /403.*Cannot access attachment/);
+    assert.equal(await getAttachment(editor, docId, i4), 'data4');
+
+    // Add another table with an attachment column, leaving access open.
+    await owner.applyUserActions(docId, [
+      ['AddTable', 'Data2', [{id: 'MorePics', type: 'Attachments'},
+                             {id: 'Unrelated', type: 'RefList:Data2'}]],
+      ['AddRecord', 'Data2', null, {}],
+    ]);
+
+    // Check that user can't gain access to an attachment by writing its id into a cell.
+    await assert.isRejected(getAttachment(editor, docId, i3), /403.*Cannot access attachment/);
+    await assert.isRejected(editor.getDocAPI(docId).updateRows(
+      'Data2',
+      {id: [1], MorePics: [[GristObjCode.List, i3]]}
+    ), /403.*Cannot access attachment/);
+    // Don't allow even sticking in an id in an unexpected format.
+    await assert.isRejected(editor.getDocAPI(docId).updateRows(
+      'Data2',
+      {id: [1], MorePics: [i3]}
+    ), /403.*Cannot access attachment/);
+    await assert.isRejected(editor.getDocAPI(docId).updateRows(
+      'Data2',
+      {id: [1], MorePics: [[GristObjCode.List, i2, i3]]}
+    ), /403.*Cannot access attachment/);
+    await assert.isFulfilled(editor.getDocAPI(docId).updateRows(
+      'Data2',
+      {id: [1], MorePics: [[GristObjCode.List, i2]]}
+    ));
+
+    // Check no confusion between columns.
+    await assert.isFulfilled(editor.getDocAPI(docId).updateRows(
+      'Data2',
+      {id: [1], MorePics: [[GristObjCode.List, i1]], Unrelated: [[GristObjCode.List, i3]]}
+    ));
+    await assert.isRejected(editor.getDocAPI(docId).updateRows(
+      'Data2',
+      {id: [1], MorePics: [[GristObjCode.List, i3]], Unrelated: [[GristObjCode.List, i2]]}
+    ), /403.*Cannot access attachment/);
+
+    // Check that user can add attachments they just uploaded.
+    const i5 = await editor.getDocAPI(docId).uploadAttachment('data5', '5.png');
+    await assert.isFulfilled(editor.getDocAPI(docId).updateRows(
+      'Data2',
+      {id: [1], MorePics: [[GristObjCode.List, i5]]}
+    ));
+
+    // Check that non-owner cannot add attachments uploaded by someone else.
+    const i6 = await owner.getDocAPI(docId).uploadAttachment('data6', '6.png');
+    await assert.isRejected(editor.getDocAPI(docId).updateRows(
+      'Data2',
+      {id: [1], MorePics: [[GristObjCode.List, i6]]}
+    ), /403.*Cannot access attachment/);
+
+    // Attachment check is not applied for undos of actions by the same user.
+    const ownerProfile = await owner.getUserProfile();
+    const editorProfile = await editor.getUserProfile();
+    const ownerInfo = {
+      user: ownerProfile.email,
+      time: Date.now(),
+    };
+    const editorInfo = {
+      user: editorProfile.email,
+      time: Date.now(),
+    };
+    // Owner mismatch case.
+    assert.match((await applyAsUndo(cliEditor, [['UpdateRecord', 'Data2', 1, {MorePics: [GristObjCode.List, i6]}]],
+                                    ownerInfo))?.error || '',
+                 /Cannot access attachment/);
+    // Old action case.
+    assert.match((await applyAsUndo(cliEditor, [['UpdateRecord', 'Data2', 1, {MorePics: [GristObjCode.List, i6]}]],
+                                    {...editorInfo, time: editorInfo.time - 48 * 60 * 60 * 1000}))?.error || '',
+                 /Cannot access attachment/);
+    // Good case.
+    assert.equal((await applyAsUndo(cliEditor, [['UpdateRecord', 'Data2', 1, {MorePics: [GristObjCode.List, i6]}]],
+                                    editorInfo))?.error || '', '');
+
+    // Check that adding an attachment to a cell a user has access to
+    // will grant them access to the attachment's contents.
+    await assert.isRejected(getAttachment(editor, docId, i3), /403.*Cannot access attachment/);
+    await owner.getDocAPI(docId).updateRows('Data2', {id: [1], MorePics: [[GristObjCode.List, i3]]});
+    assert.equal(await getAttachment(editor, docId, i3), 'data3');
+  });
+
+  it('can add attachments when there are row-level rules', async function() {
+    await freshDoc();
+
+    // Make a table, with attachments, and with row-level edit rights.
+    await owner.applyUserActions(docId, [
+      ['AddTable', 'Data1', [{id: 'A'},
+                             {id: 'Pics', type: 'Attachments'}]],
+      ['AddRecord', 'Data1', null, {A: 'edit'}],
+      ['AddRecord', 'Data1', null, {A: 'read'}],
+      ['AddRecord', 'Data1', null, {A: ''}],
+      ['AddRecord', '_grist_ACLResources', -1, {tableId: '*', colIds: '*'}],
+      ['AddRecord', '_grist_ACLResources', -2, {tableId: 'Data1', colIds: '*'}],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -1, aclFormula: 'user.Access != OWNER', permissionsText: 'none',
+      }],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -2, aclFormula: 'user.Access == OWNER', permissionsText: '+RUCD',
+      }],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -2, aclFormula: '$A == "edit"', permissionsText: '+RUCD',
+      }],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -2, aclFormula: '$A == "read"', permissionsText: '+R-UCD',
+      }],
+    ]);
+
+    // Share the document with everyone as an editor.
+    await owner.updateDocPermissions(docId, { users: { 'everyone@getgrist.com': 'editors' } });
+
+    let attachments = cliOwner.getMetaRecords('_grist_Attachments');
+    assert.lengthOf(attachments, 0);
+
+    // Add an attachment as an owner.
+    const i1 = await owner.getDocAPI(docId).uploadAttachment('data1', '1.png');
+    await owner.getDocAPI(docId).updateRows('Data1', {id: [1],
+                                                      Pics: [[GristObjCode.List, i1]]});
+
+    await cliOwner.waitForServer();
+    await cliEditor.waitForServer();
+    attachments = cliOwner.getMetaRecords('_grist_Attachments');
+    assert.lengthOf(attachments, 1);
+    attachments = cliEditor.getMetaRecords('_grist_Attachments');
+    assert.lengthOf(attachments, 1);  // record is visible to everyone because A is 'edit'
+
+    // Check an editor can add an attachment on an allowed row. Check that
+    // when doing so, the editor receives attachment metadata along with the
+    // attachment cell change.
+    cliEditor.flush();
+    cliOwner.flush();
+    const i2 = await editor.getDocAPI(docId).uploadAttachment('data2', '2.png');
+    // Owner should see attachment info already (no filtering for them).
+    let msg = await cliOwner.readMessage();
+    let gristAttachmentAction: any[] = msg.data.docActions[0];
+    assert.deepEqual(gristAttachmentAction.slice(0, 3),
+                     ['AddRecord', '_grist_Attachments', 2]);
+    await cliOwner.waitForServer();
+    await cliEditor.waitForServer();
+    attachments = cliOwner.getMetaRecords('_grist_Attachments');
+    assert.lengthOf(attachments, 2);
+    // Editor should not (need to wait until attachment is "in" a cell they can access).
+    attachments = cliEditor.getMetaRecords('_grist_Attachments');
+    assert.lengthOf(attachments, 1);
+
+    cliEditor.flush();
+    // Add the attachment in a cell. Editor should receive metadata at this point.
+    await editor.getDocAPI(docId).updateRows('Data1', {id: [1], Pics: [[GristObjCode.List, i1, i2]]});
+    msg = await cliEditor.readMessage();
+    gristAttachmentAction = msg.data.docActions[0];
+    const gristCellAction: any[] = msg.data.docActions[1];
+    assert.deepEqual(gristAttachmentAction.slice(0, 3),
+                     ['BulkAddRecord', '_grist_Attachments', [1, 2]]);
+    assert.deepEqual(gristCellAction.slice(0, 3),
+                     ['UpdateRecord', 'Data1', 1]);
+    await cliEditor.waitForServer();
+    attachments = cliEditor.getMetaRecords('_grist_Attachments');
+    assert.lengthOf(attachments, 2);
+
+    // Check an editor cannot add an attachment on a forbidden row.
+    await assert.isRejected(editor.getDocAPI(docId).updateRows('Data1', {id: [2], Pics: [[GristObjCode.List, i2]]}),
+                            /Blocked by row update access rules/);
+
+    // Check if an attachment is added to a cell the editor cannot read, they aren't
+    // told about it.
+    const i3 = await owner.getDocAPI(docId).uploadAttachment('data3', '3.png');
+    await owner.getDocAPI(docId).updateRows('Data1', {id: [3], Pics: [[GristObjCode.List, i3]]});
+    await cliOwner.waitForServer();
+    await cliEditor.waitForServer();
+    attachments = cliOwner.getMetaRecords('_grist_Attachments');
+    assert.lengthOf(attachments, 3);
+    attachments = cliEditor.getMetaRecords('_grist_Attachments');
+    assert.lengthOf(attachments, 2);
+    // Now tell them.
+    await owner.getDocAPI(docId).updateRows('Data1', {id: [3], A: ['read']});
+    msg = await cliEditor.readMessage();
+    gristAttachmentAction = msg.data.docActions[0];
+    assert.deepEqual(gristAttachmentAction.slice(0, 3),
+                     ['BulkAddRecord', '_grist_Attachments', [3]]);
+    await cliEditor.waitForServer();
+    attachments = cliEditor.getMetaRecords('_grist_Attachments');
+    assert.lengthOf(attachments, 3);
+  });
+
+  it('has access to user reference variable', async function() {
+    await freshDoc();
+    await owner.applyUserActions(docId, [
+      ['AddTable', 'Data', [{id: 'A'}]],
+    ]);
+
+    // Test that ACL rules works as usual.
+    await assert.isFulfilled(owner.applyUserActions(docId, [['AddRecord', 'Data', null, {}]]));
+    await assert.isFulfilled(editor.applyUserActions(docId, [['AddRecord', 'Data', null, {}]]));
+    // Add anonymous user as an editor.
+    await owner.updateDocPermissions(docId, { users: { "anon@getgrist.com": 'editors' } });
+    const anonym = await openClient(home.server, "anon@getgrist.com", "testy");
+    anonym.ignoreTrivialActions();
+    await anonym.openDocOnConnect(docId);
+    try {
+      // Make sure he add record too
+      let result = await anonym.send('applyUserActions', 0, [['AddRecord', 'Data', null, {}]]);
+      assert.isUndefined(result.errorCode);
+      // Now make rule, that he can't using UserRef attribute.
+      await owner.applyUserActions(docId, [
+        ['AddRecord', '_grist_ACLResources', -1, {tableId: 'Data', colIds: '*'}],
+        ['AddRecord', '_grist_ACLRules', null, {
+          resource: -1, aclFormula: 'user.UserRef is None', permissionsText: 'none',
+        }],
+      ]);
+      // Test that ACL rules works as usual for logged in user.
+      await assert.isFulfilled(owner.applyUserActions(docId, [['AddRecord', 'Data', null, {}]]));
+      await assert.isFulfilled(editor.applyUserActions(docId, [['AddRecord', 'Data', null, {}]]));
+      // Test our new rule based on UserRef attribute.
+      result = await anonym.send('applyUserActions', 0, [['AddRecord', 'Data', null, {}]]);
+      assert.equal(result.errorCode, 'ACL_DENY');
+    } finally {
+      anonym.flush();
+      await closeClient(anonym);
+    }
+  });
+
+  it('cannot modify _grist_Attachments directly when granular access applies', async function() {
+    await freshDoc();
+    await owner.applyUserActions(docId, [
+      ['AddTable', 'Data1', [{id: 'Pics', type: 'Attachments'}]],
+      ['AddRecord', '_grist_ACLResources', -1, {tableId: '*', colIds: '*'}],
+      // Add a dummy rule that doesn't change anything, just to make sure that
+      // granular access rules are processed.
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -1, aclFormula: 'user.Access in [OWNER]', permissionsText: 'all',
+      }],
+    ]);
+
+    // Add an attachment through regular mechanism.
+    const i1 = await owner.getDocAPI(docId).uploadAttachment('data1', '1.png');
+    await owner.getDocAPI(docId).addRows('Data1', {Pics: [[GristObjCode.List, i1]]});
+
+    // Try to modify _grist_Attachments by shady means.
+    await assert.isRejected(owner.getDocAPI(docId).addRows('_grist_Attachments', {fileName: ['A', 'B']}),
+                            /_grist_Attachments modification is not allowed/);
+    await assert.isRejected(owner.getDocAPI(docId).updateRows('_grist_Attachments', {id: [1], fileName: ['A']}),
+                            /_grist_Attachments modification is not allowed/);
+    await assert.isRejected(owner.getDocAPI(docId).removeRows('_grist_Attachments', [1]),
+                            /_grist_Attachments modification is not allowed/);
+  });
+
+  describe('shares', function() {
+
+    it('can give table access for a form', async function() {
+      await freshDoc();
+
+      // Publish an empty share.
+      await owner.applyUserActions(docId, [
+        ['AddRecord', '_grist_Shares', null, {
+          linkId: 'x',
+          options: '{"publish": true}'
+        }],
+      ]);
+
+      // Check it reached the home db.
+      let shares = await home.dbManager.connection.query('select * from shares');
+      assert.lengthOf(shares, 1);
+      assert.equal(shares[0].link_id, 'x');
+      assert.deepEqual(JSON.parse(shares[0].options),
+                       { publish: true });
+      assert.equal(shares[0].id, 1);
+      assert.isAtLeast(shares[0].key.length, 12);
+
+      // Check that user data is not yet available via the share.
+      const ham = await home.createHomeApi('ham', 'docs', true);
+      const hamShare = ham.getDocAPI(await getShareKeyForUrl('x'));
+      await assert.isRejected(hamShare.getRows('Table1'), /Forbidden/);
+
+      // Check that metadata is available but censored.
+      let tables = await hamShare.getRows('_grist_Tables');
+      assert.lengthOf(tables.id, 1);
+      assert.equal(tables.tableId[0], '');
+
+      // Form-share a section.
+      await owner.applyUserActions(docId, [
+        ['UpdateRecord', '_grist_Views_section', 1,
+         {shareOptions: '{"publish": true, "form": true}'}],
+        ['UpdateRecord', '_grist_Pages', 1, {shareRef: 1}],
+        ['AddRecord', 'Table1', null, {A: 1, B: 1, C: 1}],
+      ]);
+
+      // Check the appropriate table is now available.
+      tables = await hamShare.getRows('_grist_Tables');
+      assert.lengthOf(tables.id, 1);
+      assert.equal(tables.tableId[0], 'Table1');
+
+      // Check an empty read is possible. This is a
+      // convenience rather than a necessity.
+      assert.deepEqual(
+        await hamShare.getRows('Table1'),
+        { id: [], manualSort: [], A: [], C: [], B: [] }
+      );
+
+      // Owner sees all rows.
+      assert.deepEqual(
+        await owner.getDocAPI(docId).getRows('Table1'),
+        { id: [1], manualSort: [1], A: [1], C: [1], B: [1] }
+      );
+
+      // Creating a row should be allowed.
+      await hamShare.addRows('Table1', { A: [99] });
+
+      // Still don't see anything.
+      assert.deepEqual(
+        await hamShare.getRows('Table1'),
+        { id: [], manualSort: [], A: [], C: [], B: [] }
+      );
+
+      // Confirm row is actually there.
+      assert.deepEqual(
+        await owner.getDocAPI(docId).getRows('Table1'),
+        { id: [1, 2], manualSort: [1, 2], A: [1, 99], C: [1, 0], B: [1, 0] }
+      );
+
+      // Updates not allowed.
+      await assert.isRejected(hamShare.updateRows('Table1', { id: [2], A: [100] }), /Forbidden/);
+
+      // Removals not allowed.
+      await assert.isRejected(hamShare.removeRows('Table1', [2]), /Forbidden/);
+
+      // Check both operations work when you have rights.
+      await owner.getDocAPI(docId).updateRows('Table1', { id: [2], A: [100] });
+      await owner.getDocAPI(docId).removeRows('Table1', [2]);
+
+      // Modify shares options in doc, and see that they propagate.
+      await owner.applyUserActions(docId, [
+        ['UpdateRecord', '_grist_Shares', 1, {
+          options: '{"publish": true, "test": true}'
+        }],
+      ]);
+      shares = await home.dbManager.connection.query('select * from shares');
+      assert.lengthOf(shares, 1);
+      assert.deepEqual(JSON.parse(shares[0].options),
+                       {publish: true, test: true});
+
+      // Unpublish at share level, and make sure data access
+      // is now forbidden.
+      await owner.applyUserActions(docId, [
+        ['UpdateRecord', '_grist_Shares', 1, {
+          options: '{"publish": false}'
+        }],
+      ]);
+      await assert.isRejected(hamShare.getRows('Table1'), /Forbidden/);
+      await assert.isRejected(hamShare.getRows('_grist_Tables'), /Forbidden/);
+
+      await owner.applyUserActions(docId, [
+        ['RemoveRecord', '_grist_Shares', 1]
+      ]);
+      shares = await home.dbManager.connection.query('select * from shares');
+      assert.lengthOf(shares, 0);
+    });
+
+    it('can give access to referenced columns for a form', async function() {
+      // Use a fixture, since references with display columns are
+      // awkward to set up via the api
+      await freshDoc('FilmsWithImages.grist');
+
+      // Publish an empty share.
+      await owner.applyUserActions(docId, [
+        ['AddRecord', '_grist_Shares', null, {
+          linkId: 'x',
+          options: '{"publish": true}'
+        }],
+      ]);
+      await owner.applyUserActions(docId, [
+        // Turn on sharing on Friends widget on Friends page.
+        ['UpdateRecord', '_grist_Views_section', 7,
+         {shareOptions: '{"publish": true, "form": true}'}],
+        ['UpdateRecord', '_grist_Pages', 2, {shareRef: 1}],
+        // Add some access rules too - there was a bug where references were
+        // null if a multi-column table rule was present.
+        ['AddRecord', '_grist_ACLResources', -1, {tableId: 'Films', colIds: 'Title,Poster,PosterDup'}],
+        ['AddRecord', '_grist_ACLResources', -2, {tableId: 'Films', colIds: '*'}],
+        ['AddRecord', '_grist_ACLRules', null, {
+          resource: -1, aclFormula: 'user.access != OWNER', permissionsText: '-R',
+        }],
+        ['AddRecord', '_grist_ACLRules', null, {
+          resource: -2, aclFormula: 'True', permissionsText: 'all',
+        }],
+      ]);
+
+      const ham = await home.createHomeApi('ham', 'docs', true);
+      const hamDoc = ham.getDocAPI(docId);
+      const hamShare = ham.getDocAPI(await getShareKeyForUrl('x'));
+
+      // Friends looks empty.
+      assert.deepEqual(await hamShare.getRecords('Friends'), []);
+      await assert.isRejected(hamDoc.getRecords('Friends'), /Forbidden/);
+
+      // Films has just a title.
+      assert.deepEqual(await hamShare.getRecords('Films'), [
+        { id: 1, fields: { Title: 'Toy Story' } },
+        { id: 2, fields: { Title: 'Forrest Gump' } },
+        { id: 3, fields: { Title: 'Alien' } },
+        { id: 4, fields: { Title: 'Avatar' } },
+        { id: 5, fields: { Title: 'The Dark Knight' } },
+        { id: 6, fields: { Title: 'The Avengers' } }
+      ]);
+      await assert.isRejected(hamDoc.getRecords('Films'), /Forbidden/);
+
+      // Performance is not involved.
+      await assert.isRejected(hamShare.getRecords('Performances'), /Forbidden/);
+      await assert.isRejected(hamDoc.getRecords('Performances'), /Forbidden/);
+
+      // Find "Favorite Film" field on single section of "Friends" view.
+      const field = (await owner.getDocAPI(docId).sql(
+        'select v.name, v.type, t.tableId, f.id, c.colId, s.title from _grist_Views_section_field as f' +
+            ' left join _grist_Views_section s on s.id = f.parentId' +
+            ' left join _grist_Tables_column c on c.id = f.colRef' +
+            ' left join _grist_Tables t on t.id = c.parentId' +
+            ' left join _grist_Views v on v.id = s.parentId' +
+            ' where v.name = ? and c.colId = ? and s.title = ?',
+        [ 'Friends', 'Favorite_Film', '' ],
+      )).records[0].fields;
+      assert.equal(field.colId, 'Favorite_Film');
+
+      // Double check we can read film titles currently.
+      assert.deepEqual(await hamShare.getRecords('Films'), [
+        { id: 1, fields: { Title: 'Toy Story' } },
+        { id: 2, fields: { Title: 'Forrest Gump' } },
+        { id: 3, fields: { Title: 'Alien' } },
+        { id: 4, fields: { Title: 'Avatar' } },
+        { id: 5, fields: { Title: 'The Dark Knight' } },
+        { id: 6, fields: { Title: 'The Avengers' } }
+      ]);
+      // Hide the field that refers to film titles.
+      await owner.applyUserActions(docId, [[
+        "RemoveRecord", "_grist_Views_section_field", field.id,
+      ]]);
+      // Check we can no longer read film titles in the share.
+      await assert.isRejected(hamShare.getRecords('Films'), /Forbidden/);
+
+      await removeShares(docId, owner);
+    });
+
+    it('are separate from document access rules', async function() {
+      await freshDoc('FilmsWithImages.grist');
+      await owner.applyUserActions(docId, [
+        ['AddRecord', '_grist_Shares', null, {
+          linkId: 'x',
+          options: '{"publish": true}'
+        }],
+      ]);
+      await owner.applyUserActions(docId, [
+        ['UpdateRecord', '_grist_Views_section', 7,
+         {shareOptions: '{"publish": true, "form": true}'}],
+        ['UpdateRecord', '_grist_Pages', 2, {shareRef: 1}],
+      ]);
+      const ham = await home.createHomeApi('ham', 'docs', true);
+      const hamDoc = ham.getDocAPI(docId);
+      const hamShare = ham.getDocAPI(await getShareKeyForUrl('x'));
+      const ownerDoc = owner.getDocAPI(docId);
+
+      // Check that neither share nor doc can update records.
+      await owner.applyUserActions(docId, [
+        ['AddRecord', '_grist_ACLResources', -1, {tableId: 'Films', colIds: 'Title,Budget_millions'}],
+        ['AddRecord', '_grist_ACLResources', -2, {tableId: '*', colIds: '*'}],
+        ['AddRecord', '_grist_ACLRules', null, {
+          resource: -1, aclFormula: 'True', permissionsText: '+R',
+        }],
+        ['AddRecord', '_grist_ACLRules', null, {
+          resource: -2, aclFormula: 'True', permissionsText: '-U',
+        }],
+      ]);
+      assert.deepEqual(await ownerDoc.getRows('_grist_ACLRules'), {
+        id: [1, 2, 3],
+        aclColumn: [0, 0, 0],
+        resource: [1, 2, 3],
+        memo: ['', '', ''],
+        aclFormula: ['', 'True', 'True'],
+        userAttributes: ['', '', ''],
+        aclFormulaParsed: ['', '["Const", true]', '["Const", true]'],
+        permissionsText: ['', '+R', '-U'],
+        rulePos: [null, 1, 2],
+        principals: ['[1]', '', ''],
+        permissions: [63, 0, 0],
+      });
+      await assertDeniedFor(hamDoc.updateRows('Films', {id: [1], Title: ['Toy Story 2']}), [], /No view access/);
+      await assertDeniedFor(hamShare.updateRows('Films', {id: [1], Title: ['Toy Story 2']}), []);
+      await assertDeniedFor(ownerDoc.updateRows('Films', {id: [1], Title: ['Toy Story 2']}), [],
+        /Blocked by table update access rules/);
+
+      // Grant update permission to doc. Check that the share still can't update records.
+      await owner.applyUserActions(docId, [
+        ['UpdateRecord', '_grist_ACLRules', 3, {permissionsText: '+U'}],
+      ]);
+      assert.deepEqual(await ownerDoc.getRows('_grist_ACLRules'), {
+        id: [1, 2, 3],
+        aclColumn: [0, 0, 0],
+        resource: [1, 2, 3],
+        memo: ['', '', ''],
+        aclFormula: ['', 'True', 'True'],
+        userAttributes: ['', '', ''],
+        aclFormulaParsed: ['', '["Const", true]', '["Const", true]'],
+        permissionsText: ['', '+R', '+U'],
+        rulePos: [null, 1, 2],
+        principals: ['[1]', '', ''],
+        permissions: [63, 0, 0],
+      });
+      await assert.isRejected(hamDoc.updateRows('Films', {id: [1], Title: ['Toy Story 2']}), /Forbidden/);
+      await assertDeniedFor(hamShare.updateRows('Films', {id: [1], Title: ['Toy Story 2']}), []);
+      await assert.isFulfilled(ownerDoc.updateRows('Films', {id: [1], Title: ['Toy Story 2']}));
+
+      await removeShares(docId, owner);
+    });
+
+    it('can give access to a pair of form-shared widgets on same page', async function() {
+      await freshDoc('ManyRefs.grist');
+
+      // Publish an empty share.
+      await owner.applyUserActions(docId, [
+        ['AddRecord', '_grist_Shares', null, {
+          linkId: 'manyref',
+          options: '{"publish": true}'
+        }],
+      ]);
+      // viewsections 19 and 20, parent view 7, page 7.
+      await owner.applyUserActions(docId, [
+        // Turn on sharing on "Dashboard" page
+        ['UpdateRecord', '_grist_Pages', 7, {shareRef: 1}],
+        // Turn on form-sharing on "FILM" section
+        ['UpdateRecord', '_grist_Views_section', 19,
+         {shareOptions: '{"publish": true, "form": true}'}],
+        // Turn on form-sharing on "CUSTOMER" section
+        ['UpdateRecord', '_grist_Views_section', 20,
+         {shareOptions: '{"publish": true, "form": true}'}],
+      ]);
+
+      const ham = await home.createHomeApi('kiwi', 'docs', true);
+      const hamShare = ham.getDocAPI(await getShareKeyForUrl('manyref'));
+
+      // Friends looks empty - we just have rights to add records.
+      // assert.deepEqual(await anonDoc.getRecords('Film'), []);
+      // Can read some Actor columns, Codes for a Ref in one section,
+      // and Name for a RefList in another section.
+
+      // Some material is readable from Actor table for a reference
+      // and a ref list.
+      assert.deepEqual(await hamShare.getRecords('Actor'), [
+        { id: 1, fields: { Code: 'ACT101', Name: 'Impressive Name' } },
+        { id: 2, fields: { Code: 'ACT102', Name: 'Implausible Name' } }
+      ]);
+
+      // No content readable from Films, but the read is allowed
+      // (a bit of a hack to allow form-like submissions via
+      // regular web client).
+      assert.deepEqual(await hamShare.getRecords('Film'), []);
+
+      // Customer is a bit complicated. Reads allowed, but mostly
+      // no content available - EXCEPT for a column referenced by
+      // another shared widget.
+      const censored: any = [ 'C' ];
+      assert.deepEqual(await hamShare.getRecords('Customer'), [
+        {
+          id: 1,
+          fields: {
+            Name: "J Public",
+            Year_Joined: censored,
+            Good_Customer: censored,
+            Fav_Actor_Code: censored,
+          }
+        },
+        {
+          id: 2,
+          fields: {
+            Name: "K Public",
+            Year_Joined: censored,
+            Good_Customer: censored,
+            Fav_Actor_Code: censored,
+          }
+        }
+      ]);
+
+      // Make sure that basic functionality of adding rows works,
+      // for the expected tables.
+      await hamShare.addRows('Film', { Name: ['Foo'] });
+      await hamShare.addRows('Customer', { Name: ['Foo'] });
+      await assert.isRejected(hamShare.addRows('Actor', { Name: ['Foo'] }));
+      await removeShares(docId, owner);
+      const shares = await home.dbManager.connection.query('select * from shares');
+      assert.lengthOf(shares, 0);
+    });
+
+    it('can use shares after a copy', async function() {
+      await freshDoc();
+
+      // Publish an empty share.
+      await owner.applyUserActions(docId, [
+        ['AddRecord', '_grist_Shares', null, {
+          linkId: 'x2',
+          options: '{"publish": true}'
+        }],
+      ]);
+
+      // Check it reached the home db.
+      let shares = await home.dbManager.connection.query('select * from shares');
+      assert.lengthOf(shares, 1);
+      assert.equal(shares[0].link_id, 'x2');
+
+      const copyDocId = await owner.copyDoc(docId, wsId, {
+        documentName: 'copy',
+      });
+      // Do anything with the new document.
+      await owner.getDocAPI(copyDocId).getRows('Table1');
+      shares = await home.dbManager.connection.query('select * from shares');
+      assert.lengthOf(shares, 2);
+      assert.equal(shares[0].link_id, 'x2');
+      assert.equal(shares[1].link_id, 'x2');
+      assert.notEqual(shares[0].doc_id, shares[1].doc_id);
+      await removeShares(docId, owner);
+      await removeShares(copyDocId, owner);
+    });
+  });
+});
+
+async function closeClient(cli: GristClient) {
+  if (cli.ws.isOpen()) {
+    await cli.send("closeDoc", 0);
+  }
+  await cli.close();
+}
+
+// Create a wrapper to check that some property doesn't change during a test.
+function assertUnchanged(check: () => PromiseLike<any>) {
+  return async (body: PromiseLike<any>) => {
+    const pre = await check();
+    await body;
+    const post = await check();
+    assert.deepEqual(pre, post);
+  };
+}
+
+async function assertDeniedFor(check: Promise<any>, memos: string[], test = /access rules/) {
+  try {
+    await check;
+    throw new Error('not denied');
+  } catch (e) {
+    assert.match(e?.details?.userError, test);
+    assert.deepEqual(e?.details?.memos ?? [], memos);
+  }
+}
+
+// Read the content of an attachment, as text.
+async function getAttachment(api: UserAPI, docId: string, attId: number) {
+  const userApi = api as UserAPIImpl;
+  const result = await userApi.testRequest(
+    userApi.getBaseUrl() + `/api/docs/${docId}/attachments/${attId}/download`, {
+      headers: userApi.defaultHeadersWithoutContentType()
+    }
+  );
+  return result.text();
+}
+
+async function assertFlux(check: Promise<any>) {
+  try {
+    await check;
+    throw new Error('not denied');
+  } catch (e) {
+    assert.match(e?.details?.userError, /Document in flux/);
+  }
+}

From 4c2b5781dfa7ad83c1435af214cf913283888dec Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=D0=92=D0=BB=D0=B0=D0=B4=D0=B8=D0=BC=D0=B8=D1=80=20=D0=92?=
 <vakukh@gmail.com>
Date: Tue, 16 Jul 2024 18:16:00 +0000
Subject: [PATCH 060/145] Translated using Weblate (Russian)

Currently translated at 99.5% (1335 of 1341 strings)

Translation: Grist/client
Translate-URL: https://hosted.weblate.org/projects/grist/client/ru/
---
 static/locales/ru.client.json | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/static/locales/ru.client.json b/static/locales/ru.client.json
index 8b0523eb..65f7758c 100644
--- a/static/locales/ru.client.json
+++ b/static/locales/ru.client.json
@@ -549,7 +549,8 @@
         "Visit our {{link}} to learn more about Grist.": "Посетите наш {{link}} чтобы узнать больше о Grist.",
         "Sign in": "Вход",
         "To use Grist, please either sign up or sign in.": "Для использования Grist, зарегистрируйтесь или войдите в систему.",
-        "Learn more in our {{helpCenterLink}}, or find an expert via our {{sproutsProgram}}.": "Узнайте больше в {{helpCenterLink}}, или найдите специалиста с {{sproutsProgram}}."
+        "Learn more in our {{helpCenterLink}}, or find an expert via our {{sproutsProgram}}.": "Узнайте больше в {{helpCenterLink}}, или найдите специалиста с {{sproutsProgram}}.",
+        "Learn more in our {{helpCenterLink}}.": "Узнайте больше в нашем {{helpCenterLink}}."
     },
     "HomeLeftPane": {
         "Import Document": "Импорт документа",

From 4a01c68a504cc96cde190aad4525d9e2f403ad4f Mon Sep 17 00:00:00 2001
From: Paul Fitzpatrick <paulfitz@alum.mit.edu>
Date: Wed, 17 Jul 2024 17:43:04 -0400
Subject: [PATCH 061/145] bump Python version for tests, no longer supporting
 3.9 (#1112)

After changes for `PREVIOUS` and related functions, we now depend on new versions of the `bisect` function.
---
 .github/workflows/docker_latest.yml | 2 +-
 .github/workflows/main.yml          | 5 +----
 README.md                           | 2 +-
 3 files changed, 3 insertions(+), 6 deletions(-)

diff --git a/.github/workflows/docker_latest.yml b/.github/workflows/docker_latest.yml
index 7069ab50..52d75de6 100644
--- a/.github/workflows/docker_latest.yml
+++ b/.github/workflows/docker_latest.yml
@@ -48,7 +48,7 @@ jobs:
     runs-on: ubuntu-latest
     strategy:
       matrix:
-        python-version: [3.9]
+        python-version: [3.11]
         node-version: [18.x]
         image:
           # We build two images, `grist-oss` and `grist`.
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 272e3f31..547e8599 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -17,7 +17,7 @@ jobs:
       # even when there is a failure.
       fail-fast: false
       matrix:
-        python-version: [3.9]
+        python-version: [3.11]
         node-version: [18.x]
         tests:
           - ':lint:python:client:common:smoke:stubs:'
@@ -32,9 +32,6 @@ jobs:
           - tests: ':lint:python:client:common:smoke:'
             node-version: 18.x
             python-version: '3.10'
-          - tests: ':lint:python:client:common:smoke:'
-            node-version: 18.x
-            python-version: '3.11'
     steps:
       - uses: actions/checkout@v3
 
diff --git a/README.md b/README.md
index 17bf4de6..e2ba658a 100644
--- a/README.md
+++ b/README.md
@@ -464,7 +464,7 @@ Then, you can run the main test suite like so:
 yarn test
 ```
 
-Python tests may also be run locally. (Note: currently requires Python 3.9 - 3.11.)
+Python tests may also be run locally. (Note: currently requires Python 3.10 - 3.11.)
 
 ```
 yarn test:python

From 4013382170805d7a04a0943241627b346e8df08e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= <jordigh@octave.org>
Date: Wed, 17 Jul 2024 13:39:03 -0400
Subject: [PATCH 062/145] config: replace fse read functions with sync variants

I need to be able to read the config at module load time, which makes
async difficult if not impossible.

This will make read config operations synchronous, which is fine. The
file is tiny and seldom read.
---
 app/server/lib/config.ts | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/app/server/lib/config.ts b/app/server/lib/config.ts
index 067198f7..3bc495fa 100644
--- a/app/server/lib/config.ts
+++ b/app/server/lib/config.ts
@@ -2,9 +2,9 @@ import * as fse from "fs-extra";
 
 // Export dependencies for stubbing in tests.
 export const Deps = {
-  readFile: fse.readFile,
+  readFile: fse.readFileSync,
   writeFile: fse.writeFile,
-  pathExists: fse.pathExists,
+  pathExists: fse.pathExistsSync,
 };
 
 /**

From e30a090a4e0775a237478e0208fe5b56d1f9495f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= <jordigh@octave.org>
Date: Wed, 17 Jul 2024 17:46:28 -0400
Subject: [PATCH 063/145] config: remove all async/await around config read
 functions

Now that reading is synchronous, there's no need to have any more
async/await in regards to the those config functions.
---
 app/server/lib/config.ts                 | 10 +++++-----
 app/server/lib/configCore.ts             |  4 ++--
 app/server/mergedServerMain.ts           |  2 +-
 stubs/app/server/lib/globalConfig.ts     |  4 ++--
 test/server/lib/config.ts                | 12 ++++++------
 test/server/lib/configCore.ts            |  6 +++---
 test/server/lib/configCoreFileFormats.ts |  2 +-
 7 files changed, 20 insertions(+), 20 deletions(-)

diff --git a/app/server/lib/config.ts b/app/server/lib/config.ts
index 3bc495fa..490cddcf 100644
--- a/app/server/lib/config.ts
+++ b/app/server/lib/config.ts
@@ -56,15 +56,15 @@ export class FileConfig<FileContents> {
    * @param validator - Validates the contents are in the correct format, and converts to the correct type.
    *  Should throw an error or return null if not valid.
    */
-  public static async create<CreateConfigFileContents>(
+  public static create<CreateConfigFileContents>(
     configPath: string,
     validator: FileContentsValidator<CreateConfigFileContents>
-  ): Promise<FileConfig<CreateConfigFileContents>> {
+  ): FileConfig<CreateConfigFileContents> {
     // Start with empty object, as it can be upgraded to a full config.
     let rawFileContents: any = {};
 
-    if (await Deps.pathExists(configPath)) {
-      rawFileContents = JSON.parse(await Deps.readFile(configPath, 'utf8'));
+    if (Deps.pathExists(configPath)) {
+      rawFileContents = JSON.parse(Deps.readFile(configPath, 'utf8'));
     }
 
     let fileContents = null;
@@ -97,7 +97,7 @@ export class FileConfig<FileContents> {
     await this.persistToDisk();
   }
 
-  public async persistToDisk(): Promise<void> {
+  public async persistToDisk() {
     await Deps.writeFile(this._filePath, JSON.stringify(this._rawConfig, null, 2));
   }
 }
diff --git a/app/server/lib/configCore.ts b/app/server/lib/configCore.ts
index 884e2cf7..58ddf627 100644
--- a/app/server/lib/configCore.ts
+++ b/app/server/lib/configCore.ts
@@ -15,8 +15,8 @@ export interface IGristCoreConfig {
   edition: IWritableConfigValue<Edition>;
 }
 
-export async function loadGristCoreConfigFile(configPath?: string): Promise<IGristCoreConfig> {
-  const fileConfig = configPath ? await FileConfig.create(configPath, convertToCoreFileContents) : undefined;
+export function loadGristCoreConfigFile(configPath?: string): IGristCoreConfig {
+  const fileConfig = configPath ? FileConfig.create(configPath, convertToCoreFileContents) : undefined;
   return loadGristCoreConfig(fileConfig);
 }
 
diff --git a/app/server/mergedServerMain.ts b/app/server/mergedServerMain.ts
index f4e4a4a6..466eddaf 100644
--- a/app/server/mergedServerMain.ts
+++ b/app/server/mergedServerMain.ts
@@ -71,7 +71,7 @@ export async function main(port: number, serverTypes: ServerType[],
   const includeStatic = serverTypes.includes("static");
   const includeApp = serverTypes.includes("app");
 
-  options.settings ??= await getGlobalConfig();
+  options.settings ??= getGlobalConfig();
 
   const server = new FlexServer(port, `server(${serverTypes.join(",")})`, options);
 
diff --git a/stubs/app/server/lib/globalConfig.ts b/stubs/app/server/lib/globalConfig.ts
index 0c62d855..9b9c6d4c 100644
--- a/stubs/app/server/lib/globalConfig.ts
+++ b/stubs/app/server/lib/globalConfig.ts
@@ -9,10 +9,10 @@ let cachedGlobalConfig: IGristCoreConfig | undefined = undefined;
 /**
  * Retrieves the cached grist config, or loads it from the default global path.
  */
-export async function getGlobalConfig(): Promise<IGristCoreConfig> {
+export function getGlobalConfig(): IGristCoreConfig {
   if (!cachedGlobalConfig) {
     log.info(`Loading config file from ${globalConfigPath}`);
-    cachedGlobalConfig = await loadGristCoreConfigFile(globalConfigPath);
+    cachedGlobalConfig = loadGristCoreConfigFile(globalConfigPath);
   }
 
   return cachedGlobalConfig;
diff --git a/test/server/lib/config.ts b/test/server/lib/config.ts
index 711e75ec..1c7fe237 100644
--- a/test/server/lib/config.ts
+++ b/test/server/lib/config.ts
@@ -18,7 +18,7 @@ describe('FileConfig', () => {
   const useFakeConfigFile = (contents: string) => {
     const fakeFile = { contents };
     sinon.replace(Deps, 'pathExists', sinon.fake.resolves(true));
-    sinon.replace(Deps, 'readFile', sinon.fake((path, encoding: string) => Promise.resolve(fakeFile.contents)) as any);
+    sinon.replace(Deps, 'readFile', sinon.fake((path, encoding: string) => fakeFile.contents) as any);
     sinon.replace(Deps, 'writeFile', sinon.fake((path, newContents) => {
       fakeFile.contents = newContents;
       return Promise.resolve();
@@ -31,17 +31,17 @@ describe('FileConfig', () => {
     sinon.restore();
   });
 
-  it('throws an error from create if the validator does not return a value', async () => {
+  it('throws an error from create if the validator does not return a value', () => {
     useFakeConfigFile(testFileContentsJSON);
     const validator = () => null;
-    await assert.isRejected(FileConfig.create<TestFileContents>("anypath.json", validator));
+    assert.throws(() => FileConfig.create<TestFileContents>("anypath.json", validator));
   });
 
   it('persists changes when values are assigned', async () => {
     const fakeFile = useFakeConfigFile(testFileContentsJSON);
     // Don't validate - this is guaranteed to be valid above.
     const validator = (input: any) => input as TestFileContents;
-    const fileConfig = await FileConfig.create("anypath.json", validator);
+    const fileConfig = FileConfig.create("anypath.json", validator);
     await fileConfig.set("myNum", 999);
 
     assert.equal(fileConfig.get("myNum"), 999);
@@ -58,7 +58,7 @@ describe('FileConfig', () => {
     const fakeFile = useFakeConfigFile(JSON.stringify(configWithExtraProperties));
     // It's entirely possible the validator can damage the extra properties, but that's not in scope for this test.
     const validator = (input: any) => input as TestFileContents;
-    const fileConfig = await FileConfig.create("anypath.json", validator);
+    const fileConfig = FileConfig.create("anypath.json", validator);
     // Triggering a write to the file
     await fileConfig.set("myNum", 999);
     await fileConfig.set("myStr", "Something");
@@ -94,7 +94,7 @@ describe('createConfigValue', () => {
     assert.equal(accessors.get(), 2);
   });
 
-  it('initialises with the persistent value if available', async () => {
+  it('initialises with the persistent value if available', () => {
     const accessors = makeInMemoryAccessors(22);
     const configValue = createConfigValue(1, accessors);
     assert.equal(configValue.get(), 22);
diff --git a/test/server/lib/configCore.ts b/test/server/lib/configCore.ts
index 3d82ec68..50259e76 100644
--- a/test/server/lib/configCore.ts
+++ b/test/server/lib/configCore.ts
@@ -15,12 +15,12 @@ describe('loadGristCoreConfig', () => {
   });
 
   it('will function correctly when no config file is present', async () => {
-    sinon.replace(Deps, 'pathExists', sinon.fake.resolves(false));
-    sinon.replace(Deps, 'readFile', sinon.fake.resolves(""));
+    sinon.replace(Deps, 'pathExists', sinon.fake.returns(false));
+    sinon.replace(Deps, 'readFile', sinon.fake.returns("" as any));
     const writeFileFake = sinon.fake.resolves(undefined);
     sinon.replace(Deps, 'writeFile', writeFileFake);
 
-    const config = await loadGristCoreConfigFile("doesntmatter.json");
+    const config = loadGristCoreConfigFile("doesntmatter.json");
     assert.exists(config.edition.get());
 
     await config.edition.set("enterprise");
diff --git a/test/server/lib/configCoreFileFormats.ts b/test/server/lib/configCoreFileFormats.ts
index cf05c8ad..5cb99786 100644
--- a/test/server/lib/configCoreFileFormats.ts
+++ b/test/server/lib/configCoreFileFormats.ts
@@ -2,7 +2,7 @@ import { assert } from 'chai';
 import { convertToCoreFileContents, IGristCoreConfigFileLatest } from "app/server/lib/configCoreFileFormats";
 
 describe('convertToCoreFileContents', () => {
-  it('fails with a malformed config', async () => {
+  it('fails with a malformed config', () => {
     const badConfig = {
       version: "This is a random version number that will never exist",
     };

From ea1de9d220a74df4b26ccfe9cdea701043a7209a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Fran=C4=8Dek=20Prijatelj?= <prijatelj.francek@gmail.com>
Date: Thu, 18 Jul 2024 14:45:43 +0000
Subject: [PATCH 064/145] Translated using Weblate (Slovenian)

Currently translated at 100.0% (1341 of 1341 strings)

Translation: Grist/client
Translate-URL: https://hosted.weblate.org/projects/grist/client/sl/
---
 static/locales/sl.client.json | 9 +++++++--
 1 file changed, 7 insertions(+), 2 deletions(-)

diff --git a/static/locales/sl.client.json b/static/locales/sl.client.json
index a2d62da0..e7314b14 100644
--- a/static/locales/sl.client.json
+++ b/static/locales/sl.client.json
@@ -1059,7 +1059,8 @@
         "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"
+        "Event Types": "Vrste dogodkov",
+        "Header Authorization": "Pooblastilo za glavo"
     },
     "RecordLayout": {
         "Updating record layout.": "Posodobitev postavitve zapisa."
@@ -1553,7 +1554,11 @@
         "Self Checks": "Samopregledi",
         "You do not have access to the administrator panel.\nPlease log in as an administrator.": "Nimaš dostopa do skrbniške plošče.\nPrijavi se kot skrbnik.",
         "Administrator Panel Unavailable": "Skrbniška plošča ni na voljo",
-        "Grist allows different types of authentication to be configured, including SAML and OIDC. We recommend enabling one of these if Grist is accessible over the network or being made available to multiple people.": "Grist omogoča konfiguracijo različnih vrst avtentikacije, vključno s SAML in OIDC. Priporočamo, da omogočiš enega od teh, če je Grist dostopen prek omrežja ali je na voljo več osebam."
+        "Grist allows different types of authentication to be configured, including SAML and OIDC. We recommend enabling one of these if Grist is accessible over the network or being made available to multiple people.": "Grist omogoča konfiguracijo različnih vrst avtentikacije, vključno s SAML in OIDC. Priporočamo, da omogočiš enega od teh, če je Grist dostopen prek omrežja ali je na voljo več osebam.",
+        "Key to sign sessions with": "Ključ za podpisovanje sej",
+        "Session Secret": "Skrivnost seje",
+        "Grist signs user session cookies with a secret key. Please set this key via the environment variable GRIST_SESSION_SECRET. Grist falls back to a hard-coded default when it is not set. We may remove this notice in the future since session IDs have been updated to be inherently cryptographically secure.": "Grist podpisuje piškotke uporabniške seje s skrivnim ključem. Ta ključ nastavite prek spremenljivke okolja GRIST_SESSION_SECRET. Grist se vrne na trdo kodirano privzeto vrednost, če ni nastavljena. To obvestilo bomo morda odstranili v prihodnosti, saj so ID-ji sej, ustvarjeni od različice 1.1.16, sami po sebi kriptografsko varni.",
+        "Grist signs user session cookies with a secret key. Please set this key via the environment variable GRIST_SESSION_SECRET. Grist falls back to a hard-coded default when it is not set. We may remove this notice in the future as session IDs generated since v1.1.16 are inherently cryptographically secure.": "Grist podpisuje piškotke uporabniške seje s skrivnim ključem. Ta ključ nastavite prek spremenljivke okolja GRIST_SESSION_SECRET. Grist se vrne na trdo kodirano privzeto vrednost, če ni nastavljena. To obvestilo bomo morda odstranili v prihodnosti, saj so ID-ji sej, ustvarjeni od različice 1.1.16, sami po sebi kriptografsko varni."
     },
     "ChoiceEditor": {
         "Error in dropdown condition": "Napaka v spustnem meniju",

From 57cacc9e2f081f5e7128bc51c800ecf21e324c08 Mon Sep 17 00:00:00 2001
From: gallegonovato <fran-carro@hotmail.es>
Date: Sat, 20 Jul 2024 18:42:48 +0000
Subject: [PATCH 065/145] Translated using Weblate (Spanish)

Currently translated at 100.0% (1341 of 1341 strings)

Translation: Grist/client
Translate-URL: https://hosted.weblate.org/projects/grist/client/es/
---
 static/locales/es.client.json | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/static/locales/es.client.json b/static/locales/es.client.json
index f59ad29e..20c36d64 100644
--- a/static/locales/es.client.json
+++ b/static/locales/es.client.json
@@ -1295,7 +1295,8 @@
         "Enabled": "Activado",
         "Event Types": "Tipos de eventos",
         "Memo": "Memorándum",
-        "Name": "Nombre"
+        "Name": "Nombre",
+        "Header Authorization": "Encabezado de autorización"
     },
     "FormulaAssistant": {
         "Regenerate": "Regenerar",

From 916bff63b073d57ccbfc525876b0abefa94cc548 Mon Sep 17 00:00:00 2001
From: Ricky From Hong Kong <lamricky11@hotmail.com>
Date: Mon, 22 Jul 2024 13:27:38 +0000
Subject: [PATCH 066/145] Translated using Weblate (Chinese (Traditional))

Currently translated at 74.1% (994 of 1341 strings)

Translation: Grist/client
Translate-URL: https://hosted.weblate.org/projects/grist/client/zh_Hant/
---
 static/locales/zh_Hant.client.json | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/static/locales/zh_Hant.client.json b/static/locales/zh_Hant.client.json
index 3dbd4f4d..1aaa0f8d 100644
--- a/static/locales/zh_Hant.client.json
+++ b/static/locales/zh_Hant.client.json
@@ -414,7 +414,7 @@
         "Save & ": "儲存 & ",
         "Outside collaborator": "外部協作者",
         "{{collaborator}} limit exceeded": "{{collaborator}} 限制已超過",
-        "User inherits permissions from {{parent})}. To remove,           set 'Inherit access' option to 'None'.": "使用者繼承自 {{parent}} 的權限。要移除,請將 '繼承存取' 選項設為 '無'。",
+        "User inherits permissions from {{parent})}. To remove,           set 'Inherit access' option to 'None'.": "使用者繼承自 {{parent})} 的權限。要移除,請將 '繼承存取' 選項設為 '無'。",
         "Your role for this {{resourceType}}": "您在此 {{resourceType}} 中的角色",
         "Once you have removed your own access, you will not be able to get it back without assistance from someone else with sufficient access to the {{resourceType}}.": "一旦您移除了自己的存取權限,除非有其他具有足夠存取權限的人協助,否則您將無法復原對 {{resourceType}} 的存取權限。",
         "Close": "關閉",

From 43aa714137805f4a095ccac575d4f2cdcc402c56 Mon Sep 17 00:00:00 2001
From: xabirequejo <xabi.rn@gmail.com>
Date: Tue, 23 Jul 2024 10:05:38 +0200
Subject: [PATCH 067/145] Added translation using Weblate (Basque)

---
 static/locales/eu.client.json | 1 +
 1 file changed, 1 insertion(+)
 create mode 100644 static/locales/eu.client.json

diff --git a/static/locales/eu.client.json b/static/locales/eu.client.json
new file mode 100644
index 00000000..0967ef42
--- /dev/null
+++ b/static/locales/eu.client.json
@@ -0,0 +1 @@
+{}

From d982ca2103993349ec32599db2e833b1fa9eaa15 Mon Sep 17 00:00:00 2001
From: xabirequejo <xabi.rn@gmail.com>
Date: Tue, 23 Jul 2024 08:05:57 +0000
Subject: [PATCH 068/145] Translated using Weblate (Basque)

Currently translated at 40.2% (540 of 1341 strings)

Translation: Grist/client
Translate-URL: https://hosted.weblate.org/projects/grist/client/eu/
---
 static/locales/eu.client.json | 733 +++++++++++++++++++++++++++++++++-
 1 file changed, 732 insertions(+), 1 deletion(-)

diff --git a/static/locales/eu.client.json b/static/locales/eu.client.json
index 0967ef42..1a43c679 100644
--- a/static/locales/eu.client.json
+++ b/static/locales/eu.client.json
@@ -1 +1,732 @@
-{}
+{
+    "ACUserManager": {
+        "Enter email address": "Sartu ePosta helbidea",
+        "Invite new member": "Gonbidatu kide berria",
+        "We'll email an invite to {{email}}": "Gonbidapena ePostaz bidaliko dugu {{email}}(e)ra"
+    },
+    "AccessRules": {
+        "Add Default Rule": "Gehitu defektuzko araua",
+        "Add Table Rules": "Gehitu taularen arauak",
+        "Checking...": "Egiaztatzen…",
+        "Condition": "Baldintza",
+        "Default Rules": "Defektuzko arauak",
+        "Delete Table Rules": "Ezabatu taularen arauak",
+        "Enter Condition": "Sartu Baldintza",
+        "Everyone": "Guztiek",
+        "Everyone Else": "Beste guztiek",
+        "Invalid": "Ez da baliozkoa",
+        "Permissions": "Baimenak",
+        "Reset": "Berrezarri",
+        "Save": "Gorde",
+        "Saved": "Gordeta",
+        "Special Rules": "Arau bereziak",
+        "Type a message...": "Idatzi mezua…",
+        "Permission to edit document structure": "Fitxategiaren egitura editatzeko baimena",
+        "View As": "Ikusi honela",
+        "Add Column Rule": "Gehitu Zutabearen araua"
+    },
+    "AccountPage": {
+        "API": "APIa",
+        "API Key": "API gakoa",
+        "Account settings": "Kontuaren ezarpenak",
+        "Allow signing in to this account with Google": "Ahalbidetu Google erabiliz kontu honetan saioa hastea",
+        "Change Password": "Aldatu pasahitza",
+        "Edit": "Editatu",
+        "Email": "ePosta",
+        "Login Method": "Saioa hasteko modua",
+        "Name": "Izena",
+        "Names only allow letters, numbers and certain special characters": "Izenek hizkiak, zenbakiak eta karaktere berezi batzuk bakarrik izan ditzakete",
+        "Password & Security": "Pasahitza eta Segurtasuna",
+        "Save": "Gorde",
+        "Theme": "Itxura",
+        "Language": "Hizkuntza"
+    },
+    "AccountWidget": {
+        "Accounts": "Kontuak",
+        "Add Account": "Gehitu kontua",
+        "Document Settings": "Fitxategiaren ezarpenak",
+        "Manage Team": "Kudeatu Taldea",
+        "Profile Settings": "Profilaren ezarpenak",
+        "Sign Out": "Amaitu saioa",
+        "Sign in": "Hasi saioa",
+        "Switch Accounts": "Aldatu kontua",
+        "Support Grist": "Babestu Grist",
+        "Sign In": "Hasi saioa",
+        "Sign Up": "Eman izena",
+        "Use This Template": "Txantiloi hau erabili"
+    },
+    "ViewAsDropdown": {
+        "View As": "Ikusi honela"
+    },
+    "ActionLog": {
+        "All tables": "Taula guztiak"
+    },
+    "AddNewButton": {
+        "Add New": "Gehitu berria"
+    },
+    "ApiKey": {
+        "Click to show": "Egin klik erakusteko",
+        "Create": "Sortu",
+        "Remove": "Kendu",
+        "Remove API Key": "Kendu API gakoa"
+    },
+    "App": {
+        "Description": "Deskribapena",
+        "Key": "Gakoa"
+    },
+    "AppHeader": {
+        "Personal Site": "Gune pertsonala",
+        "Team Site": "Taldearen gunea",
+        "Grist Templates": "Grist txantiloiak",
+        "Manage Team": "Kudeatu Taldea"
+    },
+    "AppModel": {
+        "This team site is suspended. Documents can be read, but not modified.": "Taldearen gunea bertan behera utzi da. Fitxategiak irakur daitezke, baina ez moldatu."
+    },
+    "CellContextMenu": {
+        "Clear values": "Garbitu balioak",
+        "Copy anchor link": "Kopiatu esteka",
+        "Delete {{count}} columns_one": "Ezabatu zutabea",
+        "Delete {{count}} columns_other": "Ezabatu {{count}} zutabeak",
+        "Delete {{count}} rows_one": "Ezabatu errenkada",
+        "Delete {{count}} rows_other": "Ezabatu {{count}} errenkadak",
+        "Duplicate rows_one": "Bikoiztu errenkada",
+        "Duplicate rows_other": "Bikoiztu errenkadak",
+        "Insert column to the left": "Txertatu zutabea ezkerrean",
+        "Insert column to the right": "Txertatu zutabea eskuman",
+        "Insert row": "Txertatu errenkada",
+        "Insert row above": "Txertatu errenkada gainean",
+        "Insert row below": "Txertatu errenkada azpian",
+        "Reset {{count}} columns_one": "Berrezarri zutabea",
+        "Reset {{count}} columns_other": "Berrezarri {{count}} zutabeak",
+        "Reset {{count}} entire columns_one": "Berrezarri zutabe osoa",
+        "Reset {{count}} entire columns_other": "Berrezarri {{count}} zutabe osoak",
+        "Comment": "Iruzkina",
+        "Copy": "Kopiatu",
+        "Cut": "Ebaki",
+        "Paste": "Itsatsi"
+    },
+    "ColorSelect": {
+        "Apply": "Ezarri",
+        "Cancel": "Utzi"
+    },
+    "ColumnFilterMenu": {
+        "All": "Guztia",
+        "No matching values": "Ez dago bat datozen baliorik",
+        "Others": "Besteak",
+        "Search": "Bilatu",
+        "Search values": "Bilatu balioak",
+        "None": "Bat ere ez"
+    },
+    "CustomSectionConfig": {
+        "Add": "Gehitu",
+        "Enter Custom URL": "Sartu URL pertsonalizatua",
+        "Open configuration": "Ireki konfigurazioa",
+        "Pick a column": "Hautatu zutabea",
+        "Read selected table": "Irakurri hautatutako taula",
+        "Widget does not require any permissions.": "Widgetak ez du baimenik behar.",
+        "Clear selection": "Garbitu hautatutakoa"
+    },
+    "DataTables": {
+        "Click to copy": "Egin klik kopiatzeko",
+        "Duplicate Table": "Bikoiztu taula",
+        "Table ID copied to clipboard": "Taularen IDa arbelera kopiatu da",
+        "Remove Table": "Kendu taula",
+        "Rename Table": "Berrizendatu taula",
+        "You do not have edit access to this document": "Ez duzu fitxategi hau editatzeko sarbiderik"
+    },
+    "DocHistory": {
+        "Activity": "Jarduera",
+        "Beta": "Beta",
+        "Compare to Current": "Alderatu unekoarekin",
+        "Compare to Previous": "Alderatu aurrekoarekin"
+    },
+    "DocMenu": {
+        "Access Details": "Sarbidearen xehetasunak",
+        "All Documents": "Fitxategi guztiak",
+        "By Date Modified": "Moldatu zen dataren arabera",
+        "By Name": "Izenaren arabera",
+        "Delete": "Ezabatu",
+        "Delete Forever": "Betiko ezabatu",
+        "Delete {{name}}": "Ezabatu {{name}}",
+        "Discover More Templates": "Arakatu txantiloi gehiago",
+        "Document will be moved to Trash.": "Fitxategia zakarrontzira eramango da.",
+        "Document will be permanently deleted.": "Fitxategia betiko ezabatuko da.",
+        "Documents stay in Trash for 30 days, after which they get deleted permanently.": "Fitxategiek 30 egun ematen dituzte zakarrontzian eta, ondoren, betiko ezabatzen dira.",
+        "Examples and Templates": "Adibideak eta Txantiloiak",
+        "Featured": "Ezaugarriak",
+        "Manage Users": "Kudeatu erabiltzaileak",
+        "More Examples and Templates": "Adibide eta Txantiloi gehiago",
+        "Move": "Mugitu",
+        "Other Sites": "Beste guneak",
+        "Pin Document": "Finkatu fitxategia",
+        "Pinned Documents": "Finkatutako fitxategiak",
+        "Remove": "Kendu",
+        "Rename": "Berrizendatu",
+        "Requires edit permissions": "Editatzeko baimenak behar ditu",
+        "This service is not available right now": "Zerbitzua ez dago unean erabilgarri",
+        "Trash": "Zakarrontzia",
+        "Trash is empty.": "Zakarrontzia hutsik dago.",
+        "Unpin Document": "Utzi fitxategia finkatzeari"
+    },
+    "DocPageModel": {
+        "Add Empty Table": "Gehitu taula hutsa",
+        "Add Page": "Gehitu orria",
+        "Reload": "Birkargatu",
+        "You do not have edit access to this document": "Ez duzu fitxategi hau editatzeko sarbiderik"
+    },
+    "DocumentSettings": {
+        "Currency:": "Moneta:",
+        "Document Settings": "Fitxategiaren ezarpenak",
+        "Local currency ({{currency}})": "Moneta lokala ({{currency}})",
+        "Save": "Gorde",
+        "Save and Reload": "Gorde eta birkargatu",
+        "This document's ID (for API use):": "Fitxategi honen IDa (APIarekin erabiltzeko):",
+        "Time Zone:": "Ordu-eremua:",
+        "API": "APIa",
+        "Document ID copied to clipboard": "Fitxategiaren IDa arbelera kopiatu da",
+        "Ok": "Ados",
+        "API URL copied to clipboard": "APIaren URLa arbelera kopiatu da",
+        "API documentation.": "APIaren dokumentazioa.",
+        "Copy to clipboard": "Kopiatu arbelera",
+        "Currency": "Moneta",
+        "Document ID": "Fitxategiaren IDa",
+        "Python": "Python",
+        "Python version used": "Erabiltzen ari den Python bertsioa",
+        "Reload": "Birkargatu",
+        "Time Zone": "Ordu-eremua",
+        "Cancel": "Utzi"
+    },
+    "DocumentUsage": {
+        "Attachments Size": "Eranskinen tamaina",
+        "Usage": "Erabilera",
+        "Data Size": "Datuen tamaina",
+        "For higher limits, ": "Muga altuetarako, ",
+        "Rows": "Errenkadak"
+    },
+    "DuplicateTable": {
+        "Name for new table": "Taula berriaren izena",
+        "Copy all data in addition to the table structure.": "Kopiatu datu guztiak taularen egituraz gain."
+    },
+    "ExampleInfo": {
+        "Lightweight CRM": "CRM arina"
+    },
+    "FieldConfig": {
+        "Clear and reset": "Garbitu eta berrezarri",
+        "Empty Columns_one": "Zutabea hutsik dago",
+        "Empty Columns_other": "Zutabeak hutsik daude",
+        "Enter formula": "Sartu formula",
+        "Set formula": "Ezarri formula",
+        "DESCRIPTION": "DESKRIBAPENA"
+    },
+    "FieldMenus": {
+        "Revert to common settings": "Itzuli ohiko ezarpenetara",
+        "Save as common settings": "Gorde ohiko ezarpen gisa",
+        "Use separate settings": "Erabili ezarpen bananduak",
+        "Using common settings": "Erabili ohiko ezarpenak",
+        "Using separate settings": "Banandutako ezarpenak erabiltzen"
+    },
+    "FilterConfig": {
+        "Add Column": "Gehitu zutabea"
+    },
+    "GridOptions": {
+        "Grid Options": "Saretaren aukerak",
+        "Horizontal Gridlines": "Sareta horizontala",
+        "Vertical Gridlines": "Sareta bertikala",
+        "Zebra Stripes": "Zebra marrak"
+    },
+    "GridViewMenus": {
+        "Add Column": "Gehitu zutabea",
+        "Column Options": "Zutabearen aukerak",
+        "Freeze {{count}} columns_one": "Izoztu zutabe hau",
+        "Freeze {{count}} columns_other": "Izoztu {{count}} zutabeak",
+        "Freeze {{count}} more columns_one": "Izoztu zutabe bat gehiago",
+        "More sort options ...": "Sailkatzeko aukera gehiago…",
+        "Rename column": "Berrizendatu zutabea",
+        "Reset {{count}} columns_one": "Berrezarri zutabea",
+        "Reset {{count}} columns_other": "Berrezarri {{count}} zutabeak",
+        "Reset {{count}} entire columns_one": "Berrezarri zutabe osoa",
+        "Reset {{count}} entire columns_other": "Berrezarri {{count}} zutabe osoak",
+        "Show column {{- label}}": "Erakutsi {{- label}} zutabea",
+        "Sort": "Sailkatu",
+        "Unfreeze all columns": "Utzi zutabe guztiak izozteari",
+        "Unfreeze {{count}} columns_one": "Utzi zutabe hau izozteari",
+        "Unfreeze {{count}} columns_other": "Utzi {{count}} zutabeak izozteari",
+        "Insert column to the left": "Txertatu zutabea ezkerrean",
+        "Insert column to the right": "Txertatu zutabea eskuman",
+        "Hidden Columns": "Ezkutatutako zutabeak",
+        "Shortcuts": "Lasterbideak",
+        "Show hidden columns": "Erakutsi ezkutatutako zutabeak",
+        "Add column": "Gehitu zutabea",
+        "Any": "Edozein",
+        "Text": "Testua",
+        "Toggle": "Bai/Ez",
+        "Date": "Data",
+        "Choice List": "Aukeren zerrenda",
+        "Add to sort": "Gehitu sailkatzeko",
+        "Filter Data": "Iragazi datuak",
+        "Clear values": "Garbitu balioak",
+        "Delete {{count}} columns_one": "Ezabatu zutabea",
+        "Delete {{count}} columns_other": "Ezabatu {{count}} zutabeak",
+        "Hide {{count}} columns_other": "Ezkutatu {{count}} zutabeak",
+        "Insert column to the {{to}}": "Txertatu zutabea {{to}}",
+        "Freeze {{count}} more columns_other": "Izoztu {{count}} zutabe gehiago",
+        "Hide {{count}} columns_one": "Ezkutatu zutabea",
+        "Detect Duplicates in...": "Antzeman bikoiztutakoak…",
+        "Choice": "Aukera"
+    },
+    "HomeIntro": {
+        "Browse Templates": "Arakatu txantiloiak",
+        "Create Empty Document": "Sortu fitxategi hutsa",
+        "Invite Team Members": "Gonbidatu taldeko kideak",
+        "Visit our {{link}} to learn more.": "Bisitatu {{link}} gehiago ikasteko.",
+        "Welcome to Grist!": "Ongi etorri Grist-era!",
+        "Welcome to Grist, {{name}}!": "Ongi etorri Grist-era, {{name}}!",
+        "personal site": "gune pertsonala",
+        "{{signUp}} to save your work. ": "{{signUp}} zure lana gordetzeko. ",
+        "Welcome to Grist, {{- name}}!": "Ongi etorri Grist-era, {{- name}}!",
+        "Welcome to {{- orgName}}": "Ongi etorri {{- orgName}}(e)ra",
+        "Sign in": "Hasi saioa",
+        "To use Grist, please either sign up or sign in.": "Grist erabiltzeko eman izena edo hasi saioa.",
+        "Visit our {{link}} to learn more about Grist.": "Bisitatu {{link}} Grist-i buruz gehiago ikasteko.",
+        "Help Center": "Laguntza gunea",
+        "Import Document": "Inportatu fitxategiak",
+        "Welcome to {{orgName}}": "Ongi etorri {{orgName}}(e)ra",
+        "Sign up": "Eman izena"
+    },
+    "HomeLeftPane": {
+        "All Documents": "Fitxategi guztiak",
+        "Delete": "Ezabatu",
+        "Examples & Templates": "Txantiloiak",
+        "Import Document": "Inportatu fitxategia",
+        "Rename": "Berrizendatu",
+        "Trash": "Zakarrontzia",
+        "Manage Users": "Kudeatu erabiltzaileak",
+        "Terms of service": "Zerbitzuaren baldintzak"
+    },
+    "LeftPanelCommon": {
+        "Help Center": "Laguntza gunea"
+    },
+    "MakeCopyMenu": {
+        "As Template": "Txantiloi gisa",
+        "Cancel": "Utzi",
+        "Name": "Izena",
+        "Organization": "Erakundea",
+        "Original Looks Identical": "Jatorrizkoak berbera dirudi",
+        "Update": "Eguneratu",
+        "Update Original": "Eguneratu jatorrizkoa",
+        "Download": "Jaitsi",
+        "Download document": "Jaitsi fitxategia",
+        "Original Has Modifications": "Jatorrizkoak moldaketak ditu",
+        "Enter document name": "Sartu dokumentuaren izena",
+        "Sign up": "Eman izena"
+    },
+    "NotifyUI": {
+        "Ask for help": "Eskatu laguntza",
+        "Cannot find personal site, sorry!": "Ezin da gune pertsonala aurkitu!",
+        "Go to your free personal site": "Joan zure doako gune pertsonalera",
+        "No notifications": "Jakinarazpenik ez",
+        "Notifications": "Jakinarazpenak",
+        "Give feedback": "Eman iritzia",
+        "Renew": "Berriztu",
+        "Report a problem": "Eman akats baten berri"
+    },
+    "OnBoardingPopups": {
+        "Finish": "Amaitu",
+        "Next": "Hurrengoa",
+        "Previous": "Aurrekoa"
+    },
+    "Pages": {
+        "Delete": "Ezabatu",
+        "Delete data and this page.": "Ezabatu datuak eta orri hau.",
+        "The following tables will no longer be visible_one": "Ondorengo taula ez da aurrerantzean ikusgarri egongo",
+        "The following tables will no longer be visible_other": "Ondorengo taulak ez dira aurrerantzean ikusgarri egongo"
+    },
+    "PermissionsWidget": {
+        "Allow All": "Baimendu guztia",
+        "Deny All": "Ukatu guztia",
+        "Read Only": "Irakurri bakarrik"
+    },
+    "PluginScreen": {
+        "Import failed: ": "Inportazioak huts egin du: "
+    },
+    "RecordLayoutEditor": {
+        "Create New Field": "Sortu eremu berria",
+        "Show field {{- label}}": "Erakutsi {{- label}} eremua",
+        "Save Layout": "Gorde antolaketa",
+        "Cancel": "Utzi",
+        "Add Field": "Gehitu eremua"
+    },
+    "RefSelect": {
+        "Add Column": "Gehitu zutabea",
+        "No columns to add": "Ez dago gehitzeko zutaberik"
+    },
+    "RightPanel": {
+        "CHART TYPE": "GRAFIKO MOTA",
+        "COLUMN TYPE": "ZUTABE MOTA",
+        "Fields_one": "Eremua",
+        "Fields_other": "Eremuak",
+        "Row Style": "Errenkadaren estiloa",
+        "Sort & Filter": "Sailkatu eta iragazi",
+        "Theme": "Gaia",
+        "You do not have edit access to this document": "Ez duzu fitxategi hau editatzeko sarbiderik",
+        "Reset form": "Berrezarri formularioa",
+        "Field title": "Eremuko izena",
+        "Hidden field": "Ezkutuko eremua",
+        "Layout": "Antolaketa",
+        "Redirection": "Birzuzenketa",
+        "Enter redirect URL": "Sartu birzuzenketaren URLa",
+        "No field selected": "Ez da eremurik hautatu",
+        "Columns_one": "Zutabea",
+        "Default field value": "Eremuko defektuzko balioa",
+        "Columns_other": "Zutabeak",
+        "ROW STYLE": "ERRENKADAREN ESTILOA",
+        "Save": "Gorde",
+        "Configuration": "Konfigurazioa",
+        "Enter text": "Sartu testua",
+        "Field rules": "Eremuko arauak",
+        "Required field": "Beharrezko eremua"
+    },
+    "RowContextMenu": {
+        "Delete": "Ezabatu",
+        "Duplicate rows_one": "Bikoiztu errenkada",
+        "Duplicate rows_other": "Bikoiztu errenkadak",
+        "Insert row": "Txertatu errenkada",
+        "Insert row above": "Txertatu errenkada gainean",
+        "Insert row below": "Txertatu errenkada azpian"
+    },
+    "SelectionSummary": {
+        "Copied to clipboard": "Arbelera kopiatu da"
+    },
+    "ShareMenu": {
+        "Access Details": "Sarbidearen xehetasunak",
+        "Compare to {{termToUse}}": "Alderatu {{termToUse}}(r)ekin",
+        "Current Version": "Uneko bertsioa",
+        "Download": "Jaitsi",
+        "Duplicate Document": "Bikoiztu fitxategia",
+        "Edit without affecting the original": "Editatu jatorrizkoari eragin gabe",
+        "Export CSV": "Esportatu CSVa",
+        "Export XLSX": "Esportatu XLSXa",
+        "Manage Users": "Kudeatu erabiltzaileak",
+        "Original": "Jatorrizkoa",
+        "Replace {{termToUse}}...": "Ordeztu {{termToUse}}…",
+        "Save Document": "Gorde fitxategia",
+        "Send to Google Drive": "Bidali Google Drivera",
+        "Show in folder": "Erakutsi direktorioan",
+        "Work on a Copy": "Egin lan kopia batean",
+        "Share": "Partekatu",
+        "Download...": "Jaitsi…",
+        "Export as...": "Esportatu honela…",
+        "Microsoft Excel (.xlsx)": "Microsoft Excel (.xlsx)",
+        "Return to {{termToUse}}": "Itzuli {{termToUse}}(e)ra",
+        "Save Copy": "Gorde kopia"
+    },
+    "SiteSwitcher": {
+        "Create new team site": "Sortu taldearen gune berria",
+        "Switch Sites": "Aldatu guneak"
+    },
+    "SortConfig": {
+        "Update Data": "Eguneratu datuak",
+        "Add Column": "Gehitu zutabea"
+    },
+    "SortFilterConfig": {
+        "Filter": "IRAGAZI",
+        "Save": "Gorde",
+        "Sort": "SAILKATU",
+        "Update Sort & Filter settings": "Eguneratu sailkatze- eta iragazketa-ezarpenak"
+    },
+    "ThemeConfig": {
+        "Appearance ": "Itxura ",
+        "Switch appearance automatically to match system": "Aldatu itxura automatikoki sistemarekin bat egiteko"
+    },
+    "Tools": {
+        "Access Rules": "Sarbide-arauak",
+        "Delete": "Ezabatu",
+        "Document History": "Fitxategiaren historia",
+        "Settings": "Ezarpenak"
+    },
+    "TopBar": {
+        "Manage Team": "Kudeatu taldea"
+    },
+    "TriggerFormulas": {
+        "Cancel": "Utzi",
+        "Close": "Itxi",
+        "Current field ": "Uneko eremua ",
+        "Any field": "Edozein eremu",
+        "OK": "Ados"
+    },
+    "UserManagerModel": {
+        "Editor": "Editorea",
+        "None": "Bat ere ez",
+        "Owner": "Jabea",
+        "View & Edit": "Ikusi eta Editatu",
+        "View Only": "Ikusi soilik",
+        "Viewer": "Ikuslea"
+    },
+    "ViewConfigTab": {
+        "Form": "Formularioa",
+        "Section: ": "Atala: ",
+        "Advanced settings": "Ezarpen aurreratuak"
+    },
+    "ViewLayoutMenu": {
+        "Advanced Sort & Filter": "Sailkapen eta iragazki aurreratua",
+        "Download as CSV": "Jaitsi CSV gisa",
+        "Download as XLSX": "Jaitsi XLSX gisa",
+        "Open configuration": "Ireki konfigurazioa",
+        "Show raw data": "Erakutsi datu gordinak",
+        "Add to page": "Gehitu orrira",
+        "Create a form": "Sortu formularioa"
+    },
+    "ViewSectionMenu": {
+        "(empty)": "(hutsik)",
+        "(modified)": "(moldatua)",
+        "FILTER": "IRAGAZI",
+        "SORT": "SAILKATU",
+        "Save": "Gorde"
+    },
+    "VisibleFieldsConfig": {
+        "Clear": "Garbitu",
+        "Hidden Fields cannot be reordered": "Ezkutatutako eremuak ezin dira berrantolatu",
+        "Select All": "Hautatu guztia",
+        "Hide {{label}}": "Ezkutatu {{label}}",
+        "Show {{label}}": "Erakutsi {{label}}"
+    },
+    "WelcomeQuestions": {
+        "Education": "Hezkuntza",
+        "Other": "Besteak",
+        "Product Development": "Produktuen garapena",
+        "Research": "Ikerketa",
+        "Sales": "Salmenta",
+        "Type here": "Idatzi hemen",
+        "Welcome to Grist!": "Ongi etorri Grist-era!",
+        "What brings you to Grist? Please help us serve you better.": "Zerk zakartza Grist-era? Lagun gaitzazu zu hobeto zerbitzatzen."
+    },
+    "WidgetTitle": {
+        "Cancel": "Utzi",
+        "Save": "Gorde"
+    },
+    "duplicatePage": {
+        "Duplicate page {{pageName}}": "Bikoiztu {{pageName}} orria"
+    },
+    "errorPages": {
+        "Add account": "Gehitu kontua",
+        "Go to main page": "Joan orri nagusira",
+        "Sign in": "Hasi saioa",
+        "Sign in again": "Hasi saioa berriro",
+        "Sign in to access this organization's documents.": "Hasi saioa erakunde honen fitxategietara sarbidea izateko.",
+        "Something went wrong": "Zerbaitek huts egin du",
+        "There was an error: {{message}}": "Errore bat egon da: {{message}}",
+        "There was an unknown error.": "Errore ezezagun bat egon da.",
+        "You do not have access to this organization's documents.": "Ez duzu erakunde honen fitxategietara sarbiderik.",
+        "Sign up": "Eman izena",
+        "Your account has been deleted.": "Zure kontua ezabatu da.",
+        "An unknown error occurred.": "Errore ezezagun bat gertatu da.",
+        "Form not found": "Ez da formularioa aurkitu"
+    },
+    "menus": {
+        "Select fields": "Hautatu eremuak",
+        "Any": "Edozein",
+        "Text": "Testua",
+        "Toggle": "Bai/Ez",
+        "Date": "Data",
+        "Choice": "Aukera",
+        "Choice List": "Aukeren zerrenda",
+        "Attachment": "Eranskina"
+    },
+    "modals": {
+        "Cancel": "Utzi",
+        "Ok": "Ados",
+        "Save": "Gorde",
+        "Delete": "Ezabatu",
+        "Dismiss": "Baztertu",
+        "Don't ask again.": "Ez galdetu berriro.",
+        "Don't show tips": "Ez erakutsi aholkurik",
+        "Got it": "Ulertuta",
+        "Don't show again": "Ez erakutsi berriro",
+        "TIP": "AHOLKUA",
+        "Don't show again.": "Ez erakutsi berriro."
+    },
+    "pages": {
+        "Duplicate Page": "Bikoiztu orria",
+        "Remove": "Kendu",
+        "Rename": "Berrizendatu",
+        "You do not have edit access to this document": "Ez duzu fitxategi hau editatzeko sarbiderik"
+    },
+    "search": {
+        "Find Previous ": "Bilatu aurrekoa ",
+        "No results": "Emaitzarik ez",
+        "Search in document": "Bilatu fitxategian",
+        "Search": "Bilatu",
+        "Find Next ": "Bilatu hurrengoa "
+    },
+    "sendToDrive": {
+        "Sending file to Google Drive": "Fitxategia Google Drivera bidaltzen"
+    },
+    "NTextBox": {
+        "Lines": "Lerroak"
+    },
+    "ACLUsers": {
+        "View As": "Ikusi honela"
+    },
+    "TypeTransform": {
+        "Apply": "Ezarri",
+        "Cancel": "Utzi",
+        "Preview": "Aurrebista"
+    },
+    "ChoiceTextBox": {
+        "CHOICES": "AUKERAK"
+    },
+    "ColumnEditor": {
+        "COLUMN DESCRIPTION": "ZUTABEAREN DESKRIBAPENA"
+    },
+    "ColumnInfo": {
+        "Cancel": "Utzi",
+        "Save": "Gorde",
+        "COLUMN DESCRIPTION": "ZUTABEAREN DESKRIBAPENA",
+        "COLUMN ID: ": "ZUTABEAREN IDa: "
+    },
+    "ConditionalStyle": {
+        "Add another rule": "Gehitu beste arau bat",
+        "Row Style": "Errenkadaren estiloa",
+        "IF...": "BALDIN ETA..."
+    },
+    "DiscussionEditor": {
+        "Cancel": "Utzi",
+        "Comment": "Iruzkina",
+        "Edit": "Editatu",
+        "Marked as resolved": "Markatu konpondutzat",
+        "Only current page": "Uneko orria soilik",
+        "Remove": "Kendu",
+        "Showing last {{nb}} comments": "Erakutsi azken {{nb}} iruzkinak",
+        "Write a comment": "Idatzi iruzkina",
+        "Open": "Ireki",
+        "Reply": "Erantzun",
+        "Resolve": "Konpondu",
+        "Save": "Gorde",
+        "Reply to a comment": "Erantzun iruzkin bati",
+        "Show resolved comments": "Erakutsi konpondutako iruzkinak"
+    },
+    "FieldBuilder": {
+        "Changing column type": "Zutabe mota aldatzen"
+    },
+    "FormulaEditor": {
+        "Column or field is required": "Beharrezkoa da zutabea edo eremua",
+        "Enter formula.": "Sartu formula.",
+        "use AI Assistant": "erabili AA laguntzailea",
+        "Enter formula or {{button}}.": "Sartu formula edo {{button}}."
+    },
+    "NumericTextBox": {
+        "Default currency ({{defaultCurrency}})": "Defektuzko moneta ({{defaultCurrency}})",
+        "Text": "Testua",
+        "Number Format": "Zenbakien formatua",
+        "Decimals": "Dezimalak",
+        "Currency": "Moneta",
+        "Field Format": "Eremuaren formatua"
+    },
+    "Reference": {
+        "Row ID": "Errenkadaren IDa",
+        "SHOW COLUMN": "ERAKUTSI ZUTABEA"
+    },
+    "WelcomeTour": {
+        "Add New": "Gehitu berria",
+        "Building up": "Sortzen",
+        "Configuring your document": "Fitxategia konfiguratzen",
+        "Editing Data": "Datuak editatzen",
+        "Enter": "Sartu",
+        "Help Center": "Laguntza gunea",
+        "Use the Share button ({{share}}) to share the document or export data.": "Erabili Partekatu botoia ({{share}}) fitxategia partekatu edo datuak esportatzeko.",
+        "Welcome to Grist!": "Ongi etorri Grist-era!",
+        "template library": "txantiloi liburutegia",
+        "Share": "Partekatu",
+        "Sharing": "Partekatzen"
+    },
+    "LanguageMenu": {
+        "Language": "Hizkuntza"
+    },
+    "GristTooltips": {
+        "Learn more.": "Ikasi gehiago.",
+        "Pinning Filters": "Finkatutako iragazkiak",
+        "Add New": "Gehitu berria",
+        "Forms are here!": "Hemen dira formularioak!",
+        "Updates every 5 minutes.": "5 minuturo eguneratzen da.",
+        "Calendar": "Egutegia",
+        "Example: {{example}}": "Adibidea: {{example}}",
+        "Learn more": "Ikasi gehiago"
+    },
+    "ColumnTitle": {
+        "Column ID copied to clipboard": "Zutabearen IDa arbelera kopiatu da",
+        "Column description": "Zutabearen deskribapena",
+        "COLUMN ID: ": "ZUTABEAREN IDa: ",
+        "Add description": "Gehitu deskribapena",
+        "Close": "Itxi",
+        "Cancel": "Utzi",
+        "Save": "Gorde"
+    },
+    "Clipboard": {
+        "Got it": "Ulertuta"
+    },
+    "FieldContextMenu": {
+        "Clear field": "Garbitu eremua",
+        "Copy": "Kopiatu",
+        "Cut": "Ebaki",
+        "Hide field": "Ezkutatu eremua",
+        "Paste": "Itsatsi"
+    },
+    "WebhookPage": {
+        "Enabled": "Gaituta",
+        "Event Types": "Gertaera motak",
+        "Name": "Izena",
+        "Sorry, not all fields can be edited.": "Eremu guztiak ezin dira editatu.",
+        "Status": "Egoera",
+        "Table": "Taula",
+        "URL": "URLa"
+    },
+    "FormulaAssistant": {
+        "Ask the bot.": "Galdetu BOTari.",
+        "Capabilities": "Ahalmenak",
+        "Community": "Komunitatea",
+        "Data": "Datuak",
+        "Grist's AI Assistance": "Grist-en AA laguntzailea",
+        "Need help? Our AI assistant can help.": "Laguntza behar duzu? Gure AA laguntzaileak lagun zaitzake.",
+        "Tips": "Aholkuak",
+        "AI Assistant": "AA laguntzailea",
+        "Apply": "Ezarri",
+        "Cancel": "Utzi",
+        "Learn more": "Ikasi gehiago",
+        "Clear Conversation": "Garbitu elkarrizketa",
+        "Save": "Gorde",
+        "New Chat": "Txat berria",
+        "Preview": "Aurrebista",
+        "Regenerate": "Birsortu"
+    },
+    "ChartView": {
+        "Pick a column": "Hautatu zutabea"
+    },
+    "FilterBar": {
+        "SearchColumns": "Bilatu zutabeak",
+        "Search Columns": "Bilatu zutabea"
+    },
+    "Importer": {
+        "New Table": "Taula berria",
+        "Grist column": "Grist zutabea",
+        "Import from file": "Inportatu fitxategitik"
+    },
+    "PageWidgetPicker": {
+        "Add to Page": "Gehitu orrira",
+        "Select Data": "Hautatu datuak"
+    },
+    "ViewAsBanner": {
+        "UnknownUser": "Erabiltzaile ezezaguna"
+    },
+    "TypeTransformation": {
+        "Apply": "Ezarri",
+        "Cancel": "Utzi",
+        "Preview": "Aurrebista"
+    },
+    "ValidationPanel": {
+        "Rule {{length}}": "Araua {{length}}"
+    },
+    "DescriptionConfig": {
+        "DESCRIPTION": "DESKRIBAPENA"
+    }
+}

From 0272b6724067d27c1653357fe0030d6068bfefde Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Gr=C3=A9goire=20Cutzach?= <gregoire@cutzach.com>
Date: Tue, 23 Jul 2024 14:55:03 +0200
Subject: [PATCH 069/145] fix #1035 : Column alignment when zoom font only
 settings in browser (#1036)

Use rem value instead of fixed pixel where needed.
Removed inline style 52px for .gridview_data_row_num
---
 app/client/components/GridView.css | 18 +++++++++---------
 app/client/components/GridView.js  |  3 ++-
 2 files changed, 11 insertions(+), 10 deletions(-)

diff --git a/app/client/components/GridView.css b/app/client/components/GridView.css
index 148dfd5d..54cfa756 100644
--- a/app/client/components/GridView.css
+++ b/app/client/components/GridView.css
@@ -44,7 +44,7 @@
 }
 
 .gridview_corner_spacer { /* spacer in .gridview_data_header */
-  width: 4rem; /* matches row_num width */
+  width: 52px; /* matches row_num width */
   flex: none;
 }
 
@@ -68,7 +68,7 @@
   position: sticky;
   left: 0px;
   overflow: hidden;
-  width: 4rem; /* Also should match width for .gridview_header_corner, and the overlay elements */
+  width: 52px; /* Also should match width for .gridview_header_corner, and the overlay elements */
   flex: none;
 
   border-bottom: 1px solid var(--grist-theme-table-header-border, lightgray);
@@ -131,7 +131,7 @@
     border-left: 1px solid var(--grist-color-dark-grey);
   }
   .print-widget .gridview_data_header {
-    padding-left: 4rem !important;
+    padding-left: 52px !important;
   }
   .print-widget .gridview_data_pane .print-all-rows {
     display: table-row-group;
@@ -155,7 +155,7 @@
 }
 
 .gridview_data_corner_overlay {
-  width: 4rem;
+  width: 52px;
   height: calc(var(--gridview-header-height) + 1px); /* matches gridview_data_header height (+border) */
   top: 1px; /* go under 1px border on scrollpane */
   border-bottom: 1px solid var(--grist-theme-table-header-border, lightgray);
@@ -177,7 +177,7 @@
      - frozen-offset: when frozen columns are wider then the screen, we want them to move left initially,
                       this value is the position where this movement should stop.
    */
-  left: calc(4em + (var(--frozen-width, 0) - min(var(--frozen-scroll-offset, 0), var(--frozen-offset, 0))) * 1px);
+  left: calc(52px + (var(--frozen-width, 0) - min(var(--frozen-scroll-offset, 0), var(--frozen-offset, 0))) * 1px);
   box-shadow: -6px 0 6px 6px var(--grist-theme-table-scroll-shadow, #444);
   /* shadow should only show to the right of it (10px should be enough) */
   -webkit-clip-path: polygon(0 0, 10px 0, 10px 100%, 0 100%);
@@ -189,7 +189,7 @@
 .scroll_shadow_frozen {
   height: 100%;
   width: 0px;
-  left: 4em;
+  left: 52px;
   box-shadow: -8px 0 14px 4px var(--grist-theme-table-scroll-shadow, #444);
   -webkit-clip-path: polygon(0 0, 10px 0, 10px 100%, 0 100%);
   clip-path: polygon(0 0, 28px 0, 24px 100%, 0 100%);
@@ -205,7 +205,7 @@
   /* this value is the same as for the left shadow - but doesn't need to really on the scroll offset
      as this component will be hidden when the scroll starts
    */
-  left: calc(4em + var(--frozen-width, 0) * 1px);
+  left: calc(52px + var(--frozen-width, 0) * 1px);
   background-color: var(--grist-theme-table-frozen-columns-border, #999999);
   z-index: 30;
   user-select: none;
@@ -226,7 +226,7 @@
 }
 
 .gridview_header_backdrop_left {
-  width: calc(4rem + 1px); /* Matches rowid width (+border) */
+  width: calc(52px + 1px); /* Matches rowid width (+border) */
   height:100%;
   top: 1px; /* go under 1px border on scrollpane */
   z-index: 10;
@@ -311,7 +311,7 @@
 /* style header and a data field */
 .record .field.frozen {
   position: sticky;
-  left: calc(4em + 1px + (var(--frozen-position, 0) - var(--frozen-offset, 0)) * 1px); /* 4em for row number + total width of cells + 1px for border*/
+  left: calc(52px + 1px + (var(--frozen-position, 0) - var(--frozen-offset, 0)) * 1px); /* 52px (4em) for row number + total width of cells + 1px for border*/
   z-index: 10;
 }
 /* for data field we need to reuse color from record (add-row and zebra stripes) */
diff --git a/app/client/components/GridView.js b/app/client/components/GridView.js
index 8e3761ff..725cb028 100644
--- a/app/client/components/GridView.js
+++ b/app/client/components/GridView.js
@@ -69,9 +69,10 @@ const SHORT_CLICK_IN_MS = 500;
 
 // size of the plus width ()
 const PLUS_WIDTH = 40;
-// size of the row number field (we assume 4rem)
+// size of the row number field (we assume 4rem, 1rem = 13px in grist)
 const ROW_NUMBER_WIDTH = 52;
 
+
 /**
  * GridView component implements the view of a grid of cells.
  */

From 8162a6d9596beec15ff7951ff34395f005979c8b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jaros=C5=82aw=20Sadzi=C5=84ski?=
 <jaroslaw.sadzinski@gmail.com>
Date: Tue, 23 Jul 2024 14:50:00 +0200
Subject: [PATCH 070/145] (core) Treating x axis as category for bar chart

Summary: Forcing category xaxis type for bar chart when labels are not numerical.

Test Plan: Added new and updated existing

Reviewers: paulfitz

Reviewed By: paulfitz

Differential Revision: https://phab.getgrist.com/D4297
---
 app/client/components/ChartView.ts | 14 ++++++++--
 test/nbrowser/ChartView1.ts        | 43 +++++++++++++++++++++++++++++-
 2 files changed, 54 insertions(+), 3 deletions(-)

diff --git a/app/client/components/ChartView.ts b/app/client/components/ChartView.ts
index 3c49e2dd..37ac7574 100644
--- a/app/client/components/ChartView.ts
+++ b/app/client/components/ChartView.ts
@@ -106,8 +106,9 @@ type RowPropGetter = (rowId: number) => Datum;
 // We convert Grist data to a list of Series first, from which we then construct Plotly traces.
 interface Series {
   label: string;          // Corresponds to the column name.
-  group?: Datum;          // The group value, when grouped.
   values: Datum[];
+  pureType?: string;      // The pure type of the column.
+  group?: Datum;          // The group value, when grouped.
   isInSortSpec?: boolean; // Whether this series is present in sort spec for this chart.
 }
 
@@ -273,6 +274,7 @@ export class ChartView extends Disposable {
         const pureType = field.displayColModel().pureType();
         const fullGetter = (pureType === 'Date' || pureType === 'DateTime') ? dateGetter(getter) : getter;
         return {
+          pureType,
           label: field.label(),
           values: rowIds.map(fullGetter),
           isInSortSpec: Boolean(Sort.findCol(this._sortSpec, field.colRef.peek())),
@@ -1121,7 +1123,15 @@ function basicPlot(series: Series[], options: ChartOptions, dataOptions: Data):
 export const chartTypes: {[name: string]: ChartFunc} = {
   // TODO There is a lot of code duplication across chart types. Some refactoring is in order.
   bar(series: Series[], options: ChartOptions): PlotData {
-    return basicPlot(series, options, {type: 'bar'});
+    // If the X axis is not from numerical column, treat it as category.
+    const data = basicPlot(series, options, {type: 'bar'});
+    const useCategory = series[0]?.pureType && !['Numeric', 'Int', 'Any'].includes(series[0].pureType);
+    const xaxisName = options.orientation === 'h' ? 'yaxis' : 'xaxis';
+    if (useCategory && data.layout && data.layout[xaxisName]) {
+      const axisConfig = data.layout[xaxisName]!;
+      axisConfig.type = 'category';
+    }
+    return data;
   },
   line(series: Series[], options: ChartOptions): PlotData {
     sortByXValues(series);
diff --git a/test/nbrowser/ChartView1.ts b/test/nbrowser/ChartView1.ts
index 337431e1..b78e02f9 100644
--- a/test/nbrowser/ChartView1.ts
+++ b/test/nbrowser/ChartView1.ts
@@ -21,6 +21,47 @@ describe('ChartView1', function() {
   gu.bigScreen();
   afterEach(() => gu.checkForErrors());
 
+  it('should treat text as category', async function() {
+    const revert = await gu.begin();
+    await gu.sendActions([
+      ['AddTable', 'Text', [
+        {id: 'X', type: 'Int'},
+        {id: 'Y', type: 'Int'}
+      ]],
+      ['AddRecord', 'Text', null, {X: 1, Y: 1}],
+      ['AddRecord', 'Text', null, {X: 100, Y: 2}],
+      ['AddRecord', 'Text', null, {X: 101, Y: 3}],
+      ['AddRecord', 'Text', null, {X: 102, Y: 4}],
+    ]);
+    await gu.openPage('Text');
+    await gu.addNewSection('Chart', 'Text');
+
+    const layout = () => getChartData().then(d => d.layout);
+
+    await gu.waitToPass(async () => {
+      // Check to make sure initial values are correct.
+      assert.deepEqual((await layout()).xaxis.type, 'linear');
+    });
+
+    // Now convert X to text.
+    await gu.sendActions([
+      ['ModifyColumn', 'Text', 'X', {type: 'Text'}],
+    ]);
+    assert.deepEqual((await layout()).xaxis.type, 'category');
+
+    // Invert the chart.
+    await gu.selectSectionByTitle('Text Chart');
+    await gu.toggleSidePanel('right', 'open');
+    await driver.find('.test-chart-orientation .test-select-open').click();
+    await driver.findContent('.test-select-menu', 'Horizontal').click();
+    await gu.waitForServer();
+    assert.deepEqual((await layout()).yaxis.type, 'category');
+    assert.deepEqual((await layout()).xaxis.type, 'linear');
+
+    await revert();
+    await gu.toggleSidePanel('right', 'close');
+  });
+
   it('should allow adding and removing chart viewsections', async function() {
     // Starting out with one section
     assert.lengthOf(await driver.findAll('.test-gristdoc .view_leaf'), 1);
@@ -133,7 +174,7 @@ describe('ChartView1', function() {
 
   it('should update chart when new columns are included', async function() {
     const chartDom = await driver.find('.test-chart-container');
-    // Check to make sure intial values are correct.
+    // Check to make sure initial values are correct.
     let data = (await getChartData(chartDom)).data;
     assert.deepEqual(data[0].type, 'bar');
     assert.deepEqual(data[0].x, [ 61, 5, 4, 3, 2, 1 ]);

From 4740f1f933451cbf904754c4b138624f68ef0e5d Mon Sep 17 00:00:00 2001
From: George Gevoian <george@gevoian.com>
Date: Mon, 22 Jul 2024 11:10:57 -0400
Subject: [PATCH 071/145] (core) Update onboarding flow

Summary:
A new onboarding page is now shown to all new users visiting the doc
menu for the first time. Tutorial cards on the doc menu have been
replaced with a new version that tracks completion progress, alongside
a new card that opens the orientation video.

Test Plan: Browser tests.

Reviewers: jarek

Reviewed By: jarek

Differential Revision: https://phab.getgrist.com/D4296
---
 app/client/components/commandList.ts      |   6 -
 app/client/models/AppModel.ts             |   4 +
 app/client/models/HomeModel.ts            |  30 +-
 app/client/ui/AddNewTip.ts                |   6 -
 app/client/ui/AppUI.ts                    |   5 +
 app/client/ui/DocMenu.ts                  | 212 +++---
 app/client/ui/DocTutorial.ts              | 144 +++--
 app/client/ui/HomeLeftPane.ts             |   9 +-
 app/client/ui/OnboardingCards.ts          | 232 +++++++
 app/client/ui/OnboardingPage.ts           | 747 ++++++++++++++++++++++
 app/client/ui/OpenVideoTour.ts            |  23 +-
 app/client/ui/TutorialCard.ts             | 217 -------
 app/client/ui/WelcomeQuestions.ts         | 176 -----
 app/client/ui/YouTubePlayer.ts            |  13 +
 app/client/ui2018/IconList.ts             |   8 +
 app/common/Prefs.ts                       |   5 +-
 app/common/UserAPI.ts                     |   7 +-
 app/common/gristUrls.ts                   |   7 +-
 app/gen-server/ApiServer.ts               |  12 +
 app/gen-server/entity/Document.ts         |   6 +-
 app/server/lib/DocApi.ts                  |   5 +-
 app/server/lib/FlexServer.ts              |  13 +-
 app/server/lib/gristSettings.ts           |   6 +
 app/server/lib/sendAppPage.ts             |   3 +-
 static/icons/icons.css                    |  10 +-
 static/img/get-started.png                | Bin 0 -> 57667 bytes
 static/img/tutorial-screenshot.png        | Bin 0 -> 184287 bytes
 static/img/youtube-screenshot.png         | Bin 0 -> 278339 bytes
 static/ui-icons/Login/LoginStreamline.svg |  13 +-
 static/ui-icons/Login/LoginUnify.svg      |  13 +-
 static/ui-icons/Login/LoginVisualize.svg  |  17 +-
 static/ui-icons/UI/Skip.svg               |   3 +
 static/ui-icons/UI/Star.svg               |   3 +
 static/ui-icons/UI/VideoPlay.svg          |   6 +
 static/ui-icons/UI/VideoPlay2.svg         |   3 +
 test/fixtures/docs/GristNewUserInfo.grist | Bin 180224 -> 200704 bytes
 test/nbrowser/DocTutorial.ts              | 125 ++--
 test/nbrowser/Features.ts                 |   1 +
 test/nbrowser/HomeIntro.ts                |   6 +-
 test/nbrowser/gristUtils.ts               |  72 +--
 40 files changed, 1462 insertions(+), 706 deletions(-)
 create mode 100644 app/client/ui/OnboardingCards.ts
 create mode 100644 app/client/ui/OnboardingPage.ts
 delete mode 100644 app/client/ui/TutorialCard.ts
 delete mode 100644 app/client/ui/WelcomeQuestions.ts
 create mode 100644 static/img/get-started.png
 create mode 100644 static/img/tutorial-screenshot.png
 create mode 100644 static/img/youtube-screenshot.png
 create mode 100644 static/ui-icons/UI/Skip.svg
 create mode 100644 static/ui-icons/UI/Star.svg
 create mode 100644 static/ui-icons/UI/VideoPlay.svg
 create mode 100644 static/ui-icons/UI/VideoPlay2.svg

diff --git a/app/client/components/commandList.ts b/app/client/components/commandList.ts
index c34fc16e..532b7cab 100644
--- a/app/client/components/commandList.ts
+++ b/app/client/components/commandList.ts
@@ -25,7 +25,6 @@ export type CommandName =
   | 'expandSection'
   | 'leftPanelOpen'
   | 'rightPanelOpen'
-  | 'videoTourToolsOpen'
   | 'cursorDown'
   | 'cursorUp'
   | 'cursorRight'
@@ -269,11 +268,6 @@ export const groups: CommendGroupDef[] = [{
       keys: [],
       desc: 'Shortcut to open the right panel',
     },
-    {
-      name: 'videoTourToolsOpen',
-      keys: [],
-      desc: 'Shortcut to open video tour from home left panel',
-    },
     {
       name: 'activateAssistant',
       keys: [],
diff --git a/app/client/models/AppModel.ts b/app/client/models/AppModel.ts
index a246bee3..141bc4bc 100644
--- a/app/client/models/AppModel.ts
+++ b/app/client/models/AppModel.ts
@@ -392,6 +392,10 @@ export class AppModelImpl extends Disposable implements AppModel {
       this.behavioralPromptsManager.reset();
     };
 
+    G.window.resetOnboarding = () => {
+      getUserPrefObs(this.userPrefsObs, 'showNewUserQuestions').set(true);
+    };
+
     this.autoDispose(subscribe(urlState().state, this.topAppModel.orgs, async (_use, s, orgs) => {
       this._updateLastVisitedOrgDomain(s, orgs);
     }));
diff --git a/app/client/models/HomeModel.ts b/app/client/models/HomeModel.ts
index 56e4a84c..f5275b26 100644
--- a/app/client/models/HomeModel.ts
+++ b/app/client/models/HomeModel.ts
@@ -7,7 +7,7 @@ import {AppModel, reportError} from 'app/client/models/AppModel';
 import {reportMessage, UserError} from 'app/client/models/errors';
 import {urlState} from 'app/client/models/gristUrlState';
 import {ownerName} from 'app/client/models/WorkspaceInfo';
-import {IHomePage} from 'app/common/gristUrls';
+import {IHomePage, isFeatureEnabled} from 'app/common/gristUrls';
 import {isLongerThan} from 'app/common/gutil';
 import {SortPref, UserOrgPrefs, ViewPref} from 'app/common/Prefs';
 import * as roles from 'app/common/roles';
@@ -59,6 +59,8 @@ export interface HomeModel {
 
   shouldShowAddNewTip: Observable<boolean>;
 
+  onboardingTutorial: Observable<Document|null>;
+
   createWorkspace(name: string): Promise<void>;
   renameWorkspace(id: number, name: string): Promise<void>;
   deleteWorkspace(id: number, forever: boolean): Promise<void>;
@@ -141,6 +143,8 @@ export class HomeModelImpl extends Disposable implements HomeModel, ViewSettings
   public readonly shouldShowAddNewTip = Observable.create(this,
     !this._app.behavioralPromptsManager.hasSeenPopup('addNew'));
 
+  public readonly onboardingTutorial = Observable.create<Document|null>(this, null);
+
   private _userOrgPrefs = Observable.create<UserOrgPrefs|undefined>(this, this._app.currentOrg?.userOrgPrefs);
 
   constructor(private _app: AppModel, clientScope: ClientScope) {
@@ -176,6 +180,8 @@ export class HomeModelImpl extends Disposable implements HomeModel, ViewSettings
     this.importSources.set(importSources);
 
     this._app.refreshOrgUsage().catch(reportError);
+
+    this._loadWelcomeTutorial().catch(reportError);
   }
 
   // Accessor for the AppModel containing this HomeModel.
@@ -370,6 +376,28 @@ export class HomeModelImpl extends Disposable implements HomeModel, ViewSettings
     return templateWss;
   }
 
+  private async _loadWelcomeTutorial() {
+    const {templateOrg, onboardingTutorialDocId} = getGristConfig();
+    if (
+      !isFeatureEnabled('tutorials') ||
+      !templateOrg ||
+      !onboardingTutorialDocId ||
+      this._app.dismissedPopups.get().includes('onboardingCards')
+    ) {
+      return;
+    }
+
+    try {
+      const doc = await this._app.api.getTemplate(onboardingTutorialDocId);
+      if (this.isDisposed()) { return; }
+
+      this.onboardingTutorial.set(doc);
+    } catch (e) {
+      console.error(e);
+      reportError('Failed to load welcome tutorial');
+    }
+  }
+
   private async _saveUserOrgPref<K extends keyof UserOrgPrefs>(key: K, value: UserOrgPrefs[K]) {
     const org = this._app.currentOrg;
     if (org) {
diff --git a/app/client/ui/AddNewTip.ts b/app/client/ui/AddNewTip.ts
index 43ae62dd..2f34a497 100644
--- a/app/client/ui/AddNewTip.ts
+++ b/app/client/ui/AddNewTip.ts
@@ -1,13 +1,7 @@
 import {HomeModel} from 'app/client/models/HomeModel';
-import {shouldShowWelcomeQuestions} from 'app/client/ui/WelcomeQuestions';
 
 export function attachAddNewTip(home: HomeModel): (el: Element) => void {
   return () => {
-    const {app: {userPrefsObs}} = home;
-    if (shouldShowWelcomeQuestions(userPrefsObs)) {
-      return;
-    }
-
     if (shouldShowAddNewTip(home)) {
       showAddNewTip(home);
     }
diff --git a/app/client/ui/AppUI.ts b/app/client/ui/AppUI.ts
index 23ff48d3..5d015ea7 100644
--- a/app/client/ui/AppUI.ts
+++ b/app/client/ui/AppUI.ts
@@ -13,6 +13,7 @@ import {createDocMenu} from 'app/client/ui/DocMenu';
 import {createForbiddenPage, createNotFoundPage, createOtherErrorPage} from 'app/client/ui/errorPages';
 import {createHomeLeftPane} from 'app/client/ui/HomeLeftPane';
 import {buildSnackbarDom} from 'app/client/ui/NotifyUI';
+import {OnboardingPage, shouldShowOnboardingPage} from 'app/client/ui/OnboardingPage';
 import {pagePanels} from 'app/client/ui/PagePanels';
 import {RightPanel} from 'app/client/ui/RightPanel';
 import {createTopBarDoc, createTopBarHome} from 'app/client/ui/TopBar';
@@ -90,6 +91,10 @@ function createMainPage(appModel: AppModel, appObj: App) {
 }
 
 function pagePanelsHome(owner: IDisposableOwner, appModel: AppModel, app: App) {
+  if (shouldShowOnboardingPage(appModel.userPrefsObs)) {
+    return dom.create(OnboardingPage, appModel);
+  }
+
   const pageModel = HomeModelImpl.create(owner, appModel, app.clientScope);
   const leftPanelOpen = Observable.create(owner, true);
 
diff --git a/app/client/ui/DocMenu.ts b/app/client/ui/DocMenu.ts
index e2e149f0..af5e4905 100644
--- a/app/client/ui/DocMenu.ts
+++ b/app/client/ui/DocMenu.ts
@@ -13,16 +13,15 @@ import {attachAddNewTip} from 'app/client/ui/AddNewTip';
 import * as css from 'app/client/ui/DocMenuCss';
 import {buildHomeIntro, buildWorkspaceIntro} from 'app/client/ui/HomeIntro';
 import {buildUpgradeButton} from 'app/client/ui/ProductUpgrades';
-import {buildTutorialCard} from 'app/client/ui/TutorialCard';
 import {buildPinnedDoc, createPinnedDocs} from 'app/client/ui/PinnedDocs';
 import {shadowScroll} from 'app/client/ui/shadowScroll';
 import {makeShareDocUrl} from 'app/client/ui/ShareMenu';
 import {transition} from 'app/client/ui/transitions';
 import {shouldShowWelcomeCoachingCall, showWelcomeCoachingCall} from 'app/client/ui/WelcomeCoachingCall';
-import {shouldShowWelcomeQuestions, showWelcomeQuestions} from 'app/client/ui/WelcomeQuestions';
 import {createVideoTourTextButton} from 'app/client/ui/OpenVideoTour';
 import {buttonSelect, cssButtonSelect} from 'app/client/ui2018/buttonSelect';
 import {isNarrowScreenObs, theme} from 'app/client/ui2018/cssVars';
+import {buildOnboardingCards} from 'app/client/ui/OnboardingCards';
 import {icon} from 'app/client/ui2018/icons';
 import {loadingSpinner} from 'app/client/ui2018/loaders';
 import {menu, menuItem, menuText, select} from 'app/client/ui2018/menus';
@@ -62,10 +61,8 @@ export function createDocMenu(home: HomeModel): DomElementArg[] {
 
 function attachWelcomePopups(home: HomeModel): (el: Element) => void {
   return (element: Element) => {
-    const {app, app: {userPrefsObs}} = home;
-    if (shouldShowWelcomeQuestions(userPrefsObs)) {
-      showWelcomeQuestions(userPrefsObs);
-    } else if (shouldShowWelcomeCoachingCall(app)) {
+    const {app} = home;
+    if (shouldShowWelcomeCoachingCall(app)) {
       showWelcomeCoachingCall(element, app);
     }
   };
@@ -75,116 +72,117 @@ function createLoadedDocMenu(owner: IDisposableOwner, home: HomeModel) {
   const flashDocId = observable<string|null>(null);
   const upgradeButton = buildUpgradeButton(owner, home.app);
   return css.docList( /* vbox */
-  /* first line */
-  dom.create(buildTutorialCard, { app: home.app }),
-  /* hbox */
-  css.docListContent(
-    /* left column - grow 1 */
-    css.docMenu(
-      attachAddNewTip(home),
+    /* first line */
+    dom.create(buildOnboardingCards, {homeModel: home}),
+    /* hbox */
+    css.docListContent(
+      /* left column - grow 1 */
+      css.docMenu(
+        attachAddNewTip(home),
 
-      dom.maybe(!home.app.currentFeatures?.workspaces, () => [
-        css.docListHeader(t("This service is not available right now")),
-        dom('span', t("(The organization needs a paid plan)")),
-      ]),
+        dom.maybe(!home.app.currentFeatures?.workspaces, () => [
+          css.docListHeader(t("This service is not available right now")),
+          dom('span', t("(The organization needs a paid plan)")),
+        ]),
 
-      // currentWS and showIntro observables change together. We capture both in one domComputed call.
-      dom.domComputed<[IHomePage, Workspace|undefined, boolean]>(
-        (use) => [use(home.currentPage), use(home.currentWS), use(home.showIntro)],
-        ([page, workspace, showIntro]) => {
-          const viewSettings: ViewSettings =
-            page === 'trash' ? makeLocalViewSettings(home, 'trash') :
-            page === 'templates' ? makeLocalViewSettings(home, 'templates') :
-            workspace ? makeLocalViewSettings(home, workspace.id) :
-            home;
-          return [
-            buildPrefs(
-              viewSettings,
-              // Hide the sort and view options when showing the intro.
-              {hideSort: showIntro, hideView: showIntro && page === 'all'},
-              ['all', 'workspace'].includes(page)
-                ? upgradeButton.showUpgradeButton(css.upgradeButton.cls(''))
-                : null,
-            ),
-
-            // Build the pinned docs dom. Builds nothing if the selectedOrg is unloaded.
-            // TODO: this is shown on all pages, but there is a hack in currentWSPinnedDocs that
-            // removes all pinned docs when on trash page.
-            dom.maybe((use) => use(home.currentWSPinnedDocs).length > 0, () => [
-              css.docListHeader(css.pinnedDocsIcon('PinBig'), t("Pinned Documents")),
-              createPinnedDocs(home, home.currentWSPinnedDocs),
-            ]),
-
-            // Build the featured templates dom if on the Examples & Templates page.
-            dom.maybe((use) => page === 'templates' && use(home.featuredTemplates).length > 0, () => [
-              css.featuredTemplatesHeader(
-                css.featuredTemplatesIcon('Idea'),
-                t("Featured"),
-                testId('featured-templates-header')
+        // currentWS and showIntro observables change together. We capture both in one domComputed call.
+        dom.domComputed<[IHomePage, Workspace|undefined, boolean]>(
+          (use) => [use(home.currentPage), use(home.currentWS), use(home.showIntro)],
+          ([page, workspace, showIntro]) => {
+            const viewSettings: ViewSettings =
+              page === 'trash' ? makeLocalViewSettings(home, 'trash') :
+              page === 'templates' ? makeLocalViewSettings(home, 'templates') :
+              workspace ? makeLocalViewSettings(home, workspace.id) :
+              home;
+            return [
+              buildPrefs(
+                viewSettings,
+                // Hide the sort and view options when showing the intro.
+                {hideSort: showIntro, hideView: showIntro && page === 'all'},
+                ['all', 'workspace'].includes(page)
+                  ? upgradeButton.showUpgradeButton(css.upgradeButton.cls(''))
+                  : null,
               ),
-              createPinnedDocs(home, home.featuredTemplates, true),
-            ]),
 
-            dom.maybe(home.available, () => [
-              buildOtherSites(home),
-              (showIntro && page === 'all' ?
-                null :
-                css.docListHeader(
-                  (
-                    page === 'all' ? t("All Documents") :
-                    page === 'templates' ?
-                      dom.domComputed(use => use(home.featuredTemplates).length > 0, (hasFeaturedTemplates) =>
-                        hasFeaturedTemplates ? t("More Examples and Templates") : t("Examples and Templates")
-                    ) :
-                    page === 'trash' ? t("Trash") :
-                    workspace && [css.docHeaderIcon('Folder'), workspaceName(home.app, workspace)]
-                  ),
-                  testId('doc-header'),
-                )
-              ),
-              (
-                (page === 'all') ?
-                  dom('div',
-                    showIntro ? buildHomeIntro(home) : null,
-                    buildAllDocsBlock(home, home.workspaces, showIntro, flashDocId, viewSettings),
-                    shouldShowTemplates(home, showIntro) ? buildAllDocsTemplates(home, viewSettings) : null,
-                  ) :
-                (page === 'trash') ?
-                  dom('div',
-                    css.docBlock(t("Documents stay in Trash for 30 days, after which they get deleted permanently.")),
-                    dom.maybe((use) => use(home.trashWorkspaces).length === 0, () =>
-                      css.docBlock(t("Trash is empty."))
+              // Build the pinned docs dom. Builds nothing if the selectedOrg is unloaded.
+              // TODO: this is shown on all pages, but there is a hack in currentWSPinnedDocs that
+              // removes all pinned docs when on trash page.
+              dom.maybe((use) => use(home.currentWSPinnedDocs).length > 0, () => [
+                css.docListHeader(css.pinnedDocsIcon('PinBig'), t("Pinned Documents")),
+                createPinnedDocs(home, home.currentWSPinnedDocs),
+              ]),
+
+              // Build the featured templates dom if on the Examples & Templates page.
+              dom.maybe((use) => page === 'templates' && use(home.featuredTemplates).length > 0, () => [
+                css.featuredTemplatesHeader(
+                  css.featuredTemplatesIcon('Idea'),
+                  t("Featured"),
+                  testId('featured-templates-header')
+                ),
+                createPinnedDocs(home, home.featuredTemplates, true),
+              ]),
+
+              dom.maybe(home.available, () => [
+                buildOtherSites(home),
+                (showIntro && page === 'all' ?
+                  null :
+                  css.docListHeader(
+                    (
+                      page === 'all' ? t("All Documents") :
+                      page === 'templates' ?
+                        dom.domComputed(use => use(home.featuredTemplates).length > 0, (hasFeaturedTemplates) =>
+                          hasFeaturedTemplates ? t("More Examples and Templates") : t("Examples and Templates")
+                      ) :
+                      page === 'trash' ? t("Trash") :
+                      workspace && [css.docHeaderIcon('Folder'), workspaceName(home.app, workspace)]
                     ),
-                    buildAllDocsBlock(home, home.trashWorkspaces, false, flashDocId, viewSettings),
-                  ) :
-                (page === 'templates') ?
-                  dom('div',
-                    buildAllTemplates(home, home.templateWorkspaces, viewSettings)
-                  ) :
-                  workspace && !workspace.isSupportWorkspace && workspace.docs?.length ?
-                    css.docBlock(
-                      buildWorkspaceDocBlock(home, workspace, flashDocId, viewSettings),
-                      testId('doc-block')
+                    testId('doc-header'),
+                  )
+                ),
+                (
+                  (page === 'all') ?
+                    dom('div',
+                      showIntro ? buildHomeIntro(home) : null,
+                      buildAllDocsBlock(home, home.workspaces, showIntro, flashDocId, viewSettings),
+                      shouldShowTemplates(home, showIntro) ? buildAllDocsTemplates(home, viewSettings) : null,
                     ) :
-                  workspace && !workspace.isSupportWorkspace && workspace.docs?.length === 0 ?
-                  buildWorkspaceIntro(home) :
-                  css.docBlock(t("Workspace not found"))
-              )
-            ]),
+                  (page === 'trash') ?
+                    dom('div',
+                      css.docBlock(t("Documents stay in Trash for 30 days, after which they get deleted permanently.")),
+                      dom.maybe((use) => use(home.trashWorkspaces).length === 0, () =>
+                        css.docBlock(t("Trash is empty."))
+                      ),
+                      buildAllDocsBlock(home, home.trashWorkspaces, false, flashDocId, viewSettings),
+                    ) :
+                  (page === 'templates') ?
+                    dom('div',
+                      buildAllTemplates(home, home.templateWorkspaces, viewSettings)
+                    ) :
+                    workspace && !workspace.isSupportWorkspace && workspace.docs?.length ?
+                      css.docBlock(
+                        buildWorkspaceDocBlock(home, workspace, flashDocId, viewSettings),
+                        testId('doc-block')
+                      ) :
+                    workspace && !workspace.isSupportWorkspace && workspace.docs?.length === 0 ?
+                    buildWorkspaceIntro(home) :
+                    css.docBlock(t("Workspace not found"))
+                )
+              ]),
+            ];
+          }),
+        testId('doclist')
+      ),
+      dom.maybe(use => !use(isNarrowScreenObs()) && ['all', 'workspace'].includes(use(home.currentPage)),
+        () => {
+          // TODO: These don't currently clash (grist-core stubs the upgradeButton), but a way to
+          // manage card popups will be needed if more are added later.
+          return [
+            upgradeButton.showUpgradeCard(css.upgradeCard.cls('')),
+            home.app.supportGristNudge.buildNudgeCard(),
           ];
         }),
-      testId('doclist')
     ),
-    dom.maybe(use => !use(isNarrowScreenObs()) && ['all', 'workspace'].includes(use(home.currentPage)),
-      () => {
-        // TODO: These don't currently clash (grist-core stubs the upgradeButton), but a way to
-        // manage card popups will be needed if more are added later.
-        return [
-          upgradeButton.showUpgradeCard(css.upgradeCard.cls('')),
-          home.app.supportGristNudge.buildNudgeCard(),
-        ];
-      }),
-  ));
+  );
 }
 
 function buildAllDocsBlock(
diff --git a/app/client/ui/DocTutorial.ts b/app/client/ui/DocTutorial.ts
index d2c170df..892e1937 100644
--- a/app/client/ui/DocTutorial.ts
+++ b/app/client/ui/DocTutorial.ts
@@ -1,11 +1,12 @@
 import {GristDoc} from 'app/client/components/GristDoc';
+import {makeT} from 'app/client/lib/localization';
 import {logTelemetryEvent} from 'app/client/lib/telemetry';
 import {getWelcomeHomeUrl, urlState} from 'app/client/models/gristUrlState';
 import {renderer} from 'app/client/ui/DocTutorialRenderer';
 import {cssPopupBody, FLOATING_POPUP_TOOLTIP_KEY, FloatingPopup} from 'app/client/ui/FloatingPopup';
 import {sanitizeHTML} from 'app/client/ui/sanitizeHTML';
 import {hoverTooltip, setHoverTooltip} from 'app/client/ui/tooltips';
-import {basicButton, primaryButton} from 'app/client/ui2018/buttons';
+import {basicButton, primaryButton, textButton} from 'app/client/ui2018/buttons';
 import {mediaXSmall, theme, vars} from 'app/client/ui2018/cssVars';
 import {icon} from 'app/client/ui2018/icons';
 import {loadingSpinner} from 'app/client/ui2018/loaders';
@@ -24,6 +25,8 @@ interface DocTutorialSlide {
   imageUrls: string[];
 }
 
+const t = makeT('DocTutorial');
+
 const testId = makeTestId('test-doc-tutorial-');
 
 export class DocTutorial extends FloatingPopup {
@@ -35,12 +38,12 @@ export class DocTutorial extends FloatingPopup {
   private _docId = this._gristDoc.docId();
   private _slides: Observable<DocTutorialSlide[] | null> = Observable.create(this, null);
   private _currentSlideIndex = Observable.create(this, this._currentFork?.options?.tutorial?.lastSlideIndex ?? 0);
+  private _percentComplete = this._currentFork?.options?.tutorial?.percentComplete;
 
-
-  private _saveCurrentSlidePositionDebounced = debounce(this._saveCurrentSlidePosition, 1000, {
-    // Save new position immediately if at least 1 second has passed since the last change.
+  private _saveProgressDebounced = debounce(this._saveProgress, 1000, {
+    // Save progress immediately if at least 1 second has passed since the last change.
     leading: true,
-    // Otherwise, wait for the new position to settle for 1 second before saving it.
+    // Otherwise, wait 1 second before saving.
     trailing: true
   });
 
@@ -49,6 +52,18 @@ export class DocTutorial extends FloatingPopup {
       minimizable: true,
       stopClickPropagationOnMove: true,
     });
+
+    this.autoDispose(this._currentSlideIndex.addListener((slideIndex) => {
+      const numSlides = this._slides.get()?.length ?? 0;
+      if (numSlides > 0) {
+        this._percentComplete = Math.max(
+          Math.floor((slideIndex / numSlides) * 100),
+          this._percentComplete ?? 0
+        );
+      } else {
+        this._percentComplete = undefined;
+      }
+    }));
   }
 
   public async start() {
@@ -103,13 +118,6 @@ export class DocTutorial extends FloatingPopup {
           const isFirstSlide = slideIndex === 0;
           const isLastSlide = slideIndex === numSlides - 1;
           return [
-              cssFooterButtonsLeft(
-              cssPopupFooterButton(icon('Undo'),
-                hoverTooltip('Restart Tutorial', {key: FLOATING_POPUP_TOOLTIP_KEY}),
-                dom.on('click', () => this._restartTutorial()),
-                testId('popup-restart'),
-              ),
-            ),
             cssProgressBar(
               range(slides.length).map((i) => cssProgressBarDot(
                 hoverTooltip(slides[i].slideTitle, {
@@ -121,17 +129,17 @@ export class DocTutorial extends FloatingPopup {
                 testId(`popup-slide-${i + 1}`),
               )),
             ),
-            cssFooterButtonsRight(
-              basicButton('Previous',
+            cssFooterButtons(
+              basicButton(t('Previous'),
                 dom.on('click', async () => {
                   await this._previousSlide();
                 }),
                 {style: `visibility: ${isFirstSlide ? 'hidden' : 'visible'}`},
                 testId('popup-previous'),
               ),
-              primaryButton(isLastSlide ? 'Finish': 'Next',
+              primaryButton(isLastSlide ? t('Finish'): t('Next'),
                 isLastSlide
-                  ? dom.on('click', async () => await this._finishTutorial())
+                  ? dom.on('click', async () => await this._exitTutorial(true))
                   : dom.on('click', async () => await this._nextSlide()),
                 testId('popup-next'),
               ),
@@ -140,6 +148,21 @@ export class DocTutorial extends FloatingPopup {
         }),
         testId('popup-footer'),
       ),
+      cssTutorialControls(
+        cssTextButton(
+          cssRestartIcon('Undo'),
+          t('Restart'),
+          dom.on('click', () => this._restartTutorial()),
+          testId('popup-restart'),
+        ),
+        cssButtonsSeparator(),
+        cssTextButton(
+          cssSkipIcon('Skip'),
+          t('End tutorial'),
+          dom.on('click', () => this._exitTutorial()),
+          testId('popup-end-tutorial'),
+        ),
+      ),
     ];
   }
 
@@ -161,19 +184,13 @@ export class DocTutorial extends FloatingPopup {
   }
 
   private _logTelemetryEvent(event: 'tutorialOpened' | 'tutorialProgressChanged') {
-    const currentSlideIndex = this._currentSlideIndex.get();
-    const numSlides = this._slides.get()?.length;
-    let percentComplete: number | undefined = undefined;
-    if (numSlides !== undefined && numSlides > 0) {
-      percentComplete = Math.floor(((currentSlideIndex + 1) / numSlides) * 100);
-    }
     logTelemetryEvent(event, {
       full: {
         tutorialForkIdDigest: this._currentFork?.id,
         tutorialTrunkIdDigest: this._currentFork?.trunkId,
-        lastSlideIndex: currentSlideIndex,
-        numSlides,
-        percentComplete,
+        lastSlideIndex: this._currentSlideIndex.get(),
+        numSlides: this._slides.get()?.length,
+        percentComplete: this._percentComplete,
       },
     });
   }
@@ -251,14 +268,13 @@ export class DocTutorial extends FloatingPopup {
     }
   }
 
-  private async _saveCurrentSlidePosition() {
-    const currentOptions = this._currentDoc?.options ?? {};
-    const currentSlideIndex = this._currentSlideIndex.get();
+  private async _saveProgress() {
     await this._appModel.api.updateDoc(this._docId, {
       options: {
-        ...currentOptions,
+        ...this._currentFork?.options,
         tutorial: {
-          lastSlideIndex: currentSlideIndex,
+          lastSlideIndex: this._currentSlideIndex.get(),
+          percentComplete: this._percentComplete,
         }
       }
     });
@@ -267,7 +283,7 @@ export class DocTutorial extends FloatingPopup {
 
   private async _changeSlide(slideIndex: number) {
     this._currentSlideIndex.set(slideIndex);
-    await this._saveCurrentSlidePositionDebounced();
+    await this._saveProgressDebounced();
   }
 
   private async _previousSlide() {
@@ -278,9 +294,10 @@ export class DocTutorial extends FloatingPopup {
     await this._changeSlide(this._currentSlideIndex.get() + 1);
   }
 
-  private async _finishTutorial() {
-    this._saveCurrentSlidePositionDebounced.cancel();
-    await this._saveCurrentSlidePosition();
+  private async _exitTutorial(markAsComplete = false) {
+    this._saveProgressDebounced.cancel();
+    if (markAsComplete) { this._percentComplete = 100; }
+    await this._saveProgressDebounced();
     const lastVisitedOrg = this._appModel.lastVisitedOrgDomain.get();
     if (lastVisitedOrg) {
       await urlState().pushUrl({org: lastVisitedOrg});
@@ -298,8 +315,8 @@ export class DocTutorial extends FloatingPopup {
     };
 
     confirmModal(
-      'Do you want to restart the tutorial? All progress will be lost.',
-      'Restart',
+      t('Do you want to restart the tutorial? All progress will be lost.'),
+      t('Restart'),
       doRestart,
       {
         modalOptions: {
@@ -321,7 +338,7 @@ export class DocTutorial extends FloatingPopup {
           // eslint-disable-next-line no-self-assign
           img.src = img.src;
 
-          setHoverTooltip(img, 'Click to expand', {
+          setHoverTooltip(img, t('Click to expand'), {
             key: FLOATING_POPUP_TOOLTIP_KEY,
             modifiers: {
               flip: {
@@ -357,14 +374,13 @@ export class DocTutorial extends FloatingPopup {
   }
 }
 
-
 const cssPopupFooter = styled('div', `
   display: flex;
   column-gap: 24px;
   align-items: center;
   justify-content: space-between;
   flex-shrink: 0;
-  padding: 24px 16px 24px 16px;
+  padding: 16px;
   border-top: 1px solid ${theme.tutorialsPopupBorder};
 `);
 
@@ -375,19 +391,6 @@ const cssTryItOutBox = styled('div', `
   background-color: ${theme.tutorialsPopupBoxBg};
 `);
 
-
-
-const cssPopupFooterButton = styled('div', `
-  --icon-color: ${theme.controlSecondaryFg};
-  padding: 4px;
-  border-radius: 4px;
-  cursor: pointer;
-
-  &:hover {
-    background-color: ${theme.hover};
-  }
-`);
-
 const cssProgressBar = styled('div', `
   display: flex;
   gap: 8px;
@@ -409,11 +412,7 @@ const cssProgressBarDot = styled('div', `
   }
 `);
 
-const cssFooterButtonsLeft = styled('div', `
-  flex-shrink: 0;
-`);
-
-const cssFooterButtonsRight = styled('div', `
+const cssFooterButtons = styled('div', `
   display: flex;
   justify-content: flex-end;
   column-gap: 8px;
@@ -473,3 +472,34 @@ const cssSpinner = styled('div', `
   align-items: center;
   height: 100%;
 `);
+
+const cssTutorialControls = styled('div', `
+  background-color: ${theme.notificationsPanelHeaderBg};
+  display: flex;
+  justify-content: center;
+  padding: 8px;
+`);
+
+const cssTextButton = styled(textButton, `
+  font-weight: 500;
+  display: flex;
+  align-items: center;
+  column-gap: 4px;
+  padding: 0 16px;
+`);
+
+const cssRestartIcon = styled(icon, `
+  width: 14px;
+  height: 14px;
+`);
+
+const cssButtonsSeparator = styled('div', `
+  width: 0;
+  border-right: 1px solid ${theme.controlFg};
+`);
+
+const cssSkipIcon = styled(icon, `
+  width: 20px;
+  height: 20px;
+  margin: 0px -3px;
+`);
diff --git a/app/client/ui/HomeLeftPane.ts b/app/client/ui/HomeLeftPane.ts
index e2bbf02c..895933f7 100644
--- a/app/client/ui/HomeLeftPane.ts
+++ b/app/client/ui/HomeLeftPane.ts
@@ -31,7 +31,8 @@ export function createHomeLeftPane(leftPanelOpen: Observable<boolean>, home: Hom
   const creating = observable<boolean>(false);
   const renaming = observable<Workspace|null>(null);
   const isAnonymous = !home.app.currentValidUser;
-  const canCreate = !isAnonymous || getGristConfig().enableAnonPlayground;
+  const {enableAnonPlayground, templateOrg, onboardingTutorialDocId} = getGristConfig();
+  const canCreate = !isAnonymous || enableAnonPlayground;
 
   return cssContent(
     dom.autoDispose(creating),
@@ -114,7 +115,7 @@ export function createHomeLeftPane(leftPanelOpen: Observable<boolean>, home: Hom
       )),
       cssTools(
         cssPageEntry(
-          dom.show(isFeatureEnabled("templates") && Boolean(getGristConfig().templateOrg)),
+          dom.show(isFeatureEnabled("templates") && Boolean(templateOrg)),
           cssPageEntry.cls('-selected', (use) => use(home.currentPage) === "templates"),
           cssPageLink(cssPageIcon('Board'), cssLinkText(t("Examples & Templates")),
             urlState().setLinkUrl({homePage: "templates"}),
@@ -130,9 +131,9 @@ export function createHomeLeftPane(leftPanelOpen: Observable<boolean>, home: Hom
         ),
         cssSpacer(),
         cssPageEntry(
-          dom.show(isFeatureEnabled('tutorials')),
+          dom.show(isFeatureEnabled('tutorials') && Boolean(templateOrg && onboardingTutorialDocId)),
           cssPageLink(cssPageIcon('Bookmark'), cssLinkText(t("Tutorial")),
-            { href: commonUrls.basicTutorial, target: '_blank' },
+            urlState().setLinkUrl({org: templateOrg!, doc: onboardingTutorialDocId}),
             testId('dm-basic-tutorial'),
           ),
         ),
diff --git a/app/client/ui/OnboardingCards.ts b/app/client/ui/OnboardingCards.ts
new file mode 100644
index 00000000..9781eaf7
--- /dev/null
+++ b/app/client/ui/OnboardingCards.ts
@@ -0,0 +1,232 @@
+import {makeT} from 'app/client/lib/localization';
+import {urlState} from 'app/client/models/gristUrlState';
+import {HomeModel} from 'app/client/models/HomeModel';
+import {openVideoTour} from 'app/client/ui/OpenVideoTour';
+import {bigPrimaryButtonLink} from 'app/client/ui2018/buttons';
+import {colors, theme} from 'app/client/ui2018/cssVars';
+import {icon} from 'app/client/ui2018/icons';
+import {isFeatureEnabled} from 'app/common/gristUrls';
+import {getGristConfig} from 'app/common/urlUtils';
+import {Computed, dom, IDisposableOwner, makeTestId, styled, subscribeElem} from 'grainjs';
+
+interface BuildOnboardingCardsOptions {
+  homeModel: HomeModel;
+}
+
+const t = makeT('OnboardingCards');
+
+const testId = makeTestId('test-onboarding-');
+
+export function buildOnboardingCards(
+  owner: IDisposableOwner,
+  {homeModel}: BuildOnboardingCardsOptions
+) {
+  const {templateOrg, onboardingTutorialDocId} = getGristConfig();
+  if (!isFeatureEnabled('tutorials') || !templateOrg || !onboardingTutorialDocId) { return null; }
+
+  const percentComplete = Computed.create(owner, (use) => {
+    if (!homeModel.app.currentValidUser) { return 0; }
+
+    const tutorial = use(homeModel.onboardingTutorial);
+    if (!tutorial) { return undefined; }
+
+    return tutorial.forks?.[0]?.options?.tutorial?.percentComplete ?? 0;
+  });
+
+  const shouldShowCards = Computed.create(owner, (use) =>
+    !use(homeModel.app.dismissedPopups).includes('onboardingCards'));
+
+  let videoPlayButtonElement: HTMLElement;
+
+  return dom.maybe(shouldShowCards, () =>
+    cssOnboardingCards(
+      cssTutorialCard(
+        cssDismissCardsButton(
+          icon('CrossBig'),
+          dom.on('click', () => homeModel.app.dismissPopup('onboardingCards', true)),
+          testId('dismiss-cards'),
+        ),
+        cssTutorialCardHeader(
+          t('Complete our basics tutorial'),
+        ),
+        cssTutorialCardSubHeader(
+          t('Learn the basic of reference columns, linked widgets, column types, & cards.')
+        ),
+        cssTutorialCardBody(
+          cssTutorialProgress(
+            cssTutorialProgressText(
+              cssProgressPercentage(
+                dom.domComputed(percentComplete, (percent) => percent !== undefined ? `${percent}%` : null),
+                testId('tutorial-percent-complete'),
+              ),
+              cssStarIcon('Star'),
+            ),
+            cssTutorialProgressBar(
+              (elem) => subscribeElem(elem, percentComplete, (val) => {
+                elem.style.setProperty('--percent-complete', String(val ?? 0));
+              })
+            ),
+          ),
+          bigPrimaryButtonLink(
+            t('Complete the tutorial'),
+            urlState().setLinkUrl({org: templateOrg, doc: onboardingTutorialDocId}),
+          ),
+        ),
+        testId('tutorial-card'),
+      ),
+      cssVideoCard(
+        cssVideoThumbnail(
+          cssVideoThumbnailSpacer(),
+          videoPlayButtonElement = cssVideoPlayButton(
+            cssPlayIcon('VideoPlay2'),
+          ),
+          cssVideoThumbnailText(t('3 minute video tour')),
+        ),
+        dom.on('click', () => openVideoTour(videoPlayButtonElement)),
+      ),
+    )
+  );
+}
+
+const cssOnboardingCards = styled('div', `
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(220px, max-content));
+  gap: 24px;
+  margin: 24px 0;
+`);
+
+const cssTutorialCard = styled('div', `
+  position: relative;
+  border-radius: 4px;
+  color: ${theme.announcementPopupFg};
+  background-color: ${theme.announcementPopupBg};
+  padding: 16px 24px;
+`);
+
+const cssTutorialCardHeader = styled('div', `
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  font-size: 18px;
+  font-style: normal;
+  font-weight: 700;
+`);
+
+const cssDismissCardsButton = styled('div', `
+  position: absolute;
+  top: 8px;
+  right: 8px;
+  padding: 4px;
+  border-radius: 4px;
+  cursor: pointer;
+  --icon-color: ${theme.popupCloseButtonFg};
+
+  &:hover {
+    background-color: ${theme.hover};
+  }
+`);
+
+const cssTutorialCardSubHeader = styled('div', `
+  font-size: 14px;
+  font-style: normal;
+  font-weight: 500;
+  margin: 8px 0;
+`);
+
+const cssTutorialCardBody = styled('div', `
+  display: flex;
+  flex-wrap: wrap;
+  gap: 24px;
+  margin: 16px 0;
+  align-items: end;
+`);
+
+const cssTutorialProgress = styled('div', `
+  flex: auto;
+  min-width: 120px;
+`);
+
+const cssTutorialProgressText = styled('div', `
+  display: flex;
+  justify-content: space-between;
+`);
+
+const cssProgressPercentage = styled('div', `
+  font-size: 20px;
+  font-style: normal;
+  font-weight: 700;
+`);
+
+const cssStarIcon = styled(icon, `
+  --icon-color: ${theme.accentIcon};
+  width: 24px;
+  height: 24px;
+`);
+
+const cssTutorialProgressBar = styled('div', `
+  margin-top: 4px;
+  height: 10px;
+  border-radius: 8px;
+  background: ${theme.mainPanelBg};
+  --percent-complete: 0;
+
+  &::after {
+    content: '';
+    border-radius: 8px;
+    background: ${theme.progressBarFg};
+    display: block;
+    height: 100%;
+    width: calc((var(--percent-complete) / 100) * 100%);
+  }
+`);
+
+const cssVideoCard = styled('div', `
+  width: 220px;
+  height: 158px;
+  overflow: hidden;
+  cursor: pointer;
+  border-radius: 4px;
+`);
+
+const cssVideoThumbnail = styled('div', `
+  display: flex;
+  flex-direction: column;
+  justify-content: space-between;
+  padding: 36px 32px;
+  background-image: url("img/youtube-screenshot.png");
+  background-color: rgba(0, 0, 0, 0.4);
+  background-blend-mode: multiply;
+  background-size: cover;
+  transform: scale(1.2);
+  width: 100%;
+  height: 100%;
+`);
+
+const cssVideoThumbnailSpacer = styled('div', ``);
+
+const cssVideoPlayButton = styled('div', `
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  align-self: center;
+  width: 32px;
+  height: 32px;
+  background-color: ${theme.controlPrimaryBg};
+  border-radius: 50%;
+
+  .${cssVideoThumbnail.className}:hover & {
+    background-color: ${theme.controlPrimaryHoverBg};
+  }
+`);
+
+const cssPlayIcon = styled(icon, `
+  --icon-color: ${theme.controlPrimaryFg};
+  width: 24px;
+  height: 24px;
+`);
+
+const cssVideoThumbnailText = styled('div', `
+  color: ${colors.light};
+  font-weight: 700;
+  text-align: center;
+`);
diff --git a/app/client/ui/OnboardingPage.ts b/app/client/ui/OnboardingPage.ts
new file mode 100644
index 00000000..ce2b7425
--- /dev/null
+++ b/app/client/ui/OnboardingPage.ts
@@ -0,0 +1,747 @@
+import {FocusLayer} from 'app/client/lib/FocusLayer';
+import {makeT} from 'app/client/lib/localization';
+import {AppModel} from 'app/client/models/AppModel';
+import {logError} from 'app/client/models/errors';
+import {getMainOrgUrl, urlState} from 'app/client/models/gristUrlState';
+import {getUserPrefObs} from 'app/client/models/UserPrefs';
+import {textInput} from 'app/client/ui/inputs';
+import {PlayerState, YouTubePlayer} from 'app/client/ui/YouTubePlayer';
+import {bigBasicButton, bigPrimaryButton, bigPrimaryButtonLink} from 'app/client/ui2018/buttons';
+import {colors, mediaMedium, mediaXSmall, theme} from 'app/client/ui2018/cssVars';
+import {icon} from 'app/client/ui2018/icons';
+import {IconName} from 'app/client/ui2018/IconList';
+import {modal} from 'app/client/ui2018/modals';
+import {BaseAPI} from 'app/common/BaseAPI';
+import {getPageTitleSuffix, ONBOARDING_VIDEO_YOUTUBE_EMBED_ID} from 'app/common/gristUrls';
+import {UserPrefs} from 'app/common/Prefs';
+import {getGristConfig} from 'app/common/urlUtils';
+import {
+  Computed,
+  Disposable,
+  dom,
+  DomContents,
+  IDisposableOwner,
+  input,
+  makeTestId,
+  Observable,
+  styled,
+  subscribeElem,
+} from 'grainjs';
+
+const t = makeT('OnboardingPage');
+
+const testId = makeTestId('test-onboarding-');
+
+const choices: Array<{icon: IconName, color: string, textKey: string}> = [
+  {icon: 'UseProduct', color: `${colors.lightGreen}`, textKey: 'Product Development' },
+  {icon: 'UseFinance', color: '#0075A2',              textKey: 'Finance & Accounting'},
+  {icon: 'UseMedia',   color: '#F7B32B',              textKey: 'Media Production'    },
+  {icon: 'UseMonitor', color: '#F2545B',              textKey: 'IT & Technology'     },
+  {icon: 'UseChart',   color: '#7141F9',              textKey: 'Marketing'           },
+  {icon: 'UseScience', color: '#231942',              textKey: 'Research'            },
+  {icon: 'UseSales',   color: '#885A5A',              textKey: 'Sales'               },
+  {icon: 'UseEducate', color: '#4A5899',              textKey: 'Education'           },
+  {icon: 'UseHr',      color: '#688047',              textKey: 'HR & Management'     },
+  {icon: 'UseOther',   color: '#929299',              textKey: 'Other'               },
+];
+
+export function shouldShowOnboardingPage(userPrefsObs: Observable<UserPrefs>): boolean {
+  return Boolean(getGristConfig().survey && userPrefsObs.get()?.showNewUserQuestions);
+}
+
+type IncrementStep = (delta?: 1 | -1) => void;
+
+interface Step {
+  state?: QuestionsState | VideoState;
+  buildDom(): DomContents;
+  onNavigateAway?(): void;
+}
+
+interface QuestionsState {
+  organization: Observable<string>;
+  role: Observable<string>;
+  useCases: Array<Observable<boolean>>;
+  useOther: Observable<string>;
+}
+
+interface VideoState {
+  watched: Observable<boolean>;
+}
+
+export class OnboardingPage extends Disposable {
+  private _steps: Array<Step>;
+  private _stepIndex: Observable<number> = Observable.create(this, 0);
+
+  constructor(private _appModel: AppModel) {
+    super();
+
+    this.autoDispose(this._stepIndex.addListener((_, prevIndex) => {
+      this._steps[prevIndex].onNavigateAway?.();
+    }));
+
+    const incrementStep: IncrementStep = (delta: -1 | 1 = 1) => {
+      this._stepIndex.set(this._stepIndex.get() + delta);
+    };
+
+    this._steps = [
+      {
+        state: {
+          organization: Observable.create(this, ''),
+          role: Observable.create(this, ''),
+          useCases: choices.map(() => Observable.create(this, false)),
+          useOther: Observable.create(this, ''),
+        },
+        buildDom() { return dom.create(buildQuestions, incrementStep, this.state as QuestionsState); },
+        onNavigateAway() { saveQuestions(this.state as QuestionsState); },
+      },
+      {
+        state: {
+          watched: Observable.create(this, false),
+        },
+        buildDom() { return dom.create(buildVideo, incrementStep, this.state as VideoState); },
+      },
+      {
+        buildDom() { return dom.create(buildTutorial, incrementStep); },
+      },
+    ];
+
+    document.title = `Welcome${getPageTitleSuffix(getGristConfig())}`;
+
+    getUserPrefObs(this._appModel.userPrefsObs, 'showNewUserQuestions').set(undefined);
+  }
+
+  public buildDom() {
+    return cssPageContainer(
+      cssOnboardingPage(
+        cssSidebar(
+          cssSidebarContent(
+            cssSidebarHeading1(t('Welcome')),
+            cssSidebarHeading2(this._appModel.currentUser!.name + '!'),
+            testId('sidebar'),
+          ),
+          cssGetStarted(
+            cssGetStartedImg({src: 'img/get-started.png'}),
+          ),
+        ),
+        cssMainPanel(
+          buildStepper(this._steps, this._stepIndex),
+          dom.domComputed(this._stepIndex, index => {
+            return this._steps[index].buildDom();
+          }),
+        ),
+        testId('page'),
+      ),
+    );
+  }
+}
+
+function buildStepper(steps: Step[], stepIndex: Observable<number>) {
+  return cssStepper(
+    steps.map((_, i) =>
+      cssStep(
+        cssStepCircle(
+          cssStepCircle.cls('-done', use => (i < use(stepIndex))),
+          dom.domComputed(use => i < use(stepIndex), (done) => done ? icon('Tick') : String(i + 1)),
+          cssStepCircle.cls('-current', use => (i === use(stepIndex))),
+          dom.on('click', () => { stepIndex.set(i); }),
+          testId(`step-${i + 1}`)
+        )
+      )
+    )
+  );
+}
+
+function saveQuestions(state: QuestionsState) {
+  const {organization, role, useCases, useOther} = state;
+  if (!organization.get() && !role.get() && !useCases.map(useCase => useCase.get()).includes(true)) {
+    return;
+  }
+
+  const org_name = organization.get();
+  const org_role = role.get();
+  const use_cases = choices.filter((c, i) => useCases[i].get()).map(c => c.textKey);
+  const use_other = use_cases.includes('Other') ? useOther.get() : '';
+  const submitUrl = new URL(window.location.href);
+  submitUrl.pathname = '/welcome/info';
+  BaseAPI.request(submitUrl.href, {
+    method: 'POST',
+    body: JSON.stringify({org_name, org_role, use_cases, use_other})
+  }).catch((e) => logError(e));
+}
+
+function buildQuestions(owner: IDisposableOwner, incrementStep: IncrementStep, state: QuestionsState) {
+  const {organization, role, useCases, useOther} = state;
+  const isFilled = Computed.create(owner, (use) => {
+    return Boolean(use(organization) || use(role) || useCases.map(useCase => use(useCase)).includes(true));
+  });
+
+  return cssQuestions(
+    cssHeading(t("Tell us who you are")),
+    cssQuestion(
+      cssFieldHeading(t('What organization are you with?')),
+      cssInput(
+        organization,
+        {type: 'text', placeholder: t('Your organization')},
+        testId('questions-organization'),
+      ),
+    ),
+    cssQuestion(
+      cssFieldHeading(t('What is your role?')),
+      cssInput(
+        role,
+        {type: 'text', placeholder: t('Your role')},
+        testId('questions-role'),
+      ),
+    ),
+    cssQuestion(
+      cssFieldHeading(t("What brings you to Grist (you can select multiple)?")),
+      cssUseCases(
+        choices.map((item, i) => cssUseCase(
+          cssUseCaseIcon(icon(item.icon)),
+          cssUseCase.cls('-selected', useCases[i]),
+          dom.on('click', () => useCases[i].set(!useCases[i].get())),
+          (item.icon !== 'UseOther' ?
+            t(item.textKey) :
+            [
+              cssOtherLabel(t(item.textKey)),
+              cssOtherInput(useOther, {}, {type: 'text', placeholder: t("Type here")},
+                // The following subscribes to changes to selection observable, and focuses the input when
+                // this item is selected.
+                (elem) => subscribeElem(elem, useCases[i], val => val && setTimeout(() => elem.focus(), 0)),
+                // It's annoying if clicking into the input toggles selection; better to turn that
+                // off (user can click icon to deselect).
+                dom.on('click', ev => ev.stopPropagation()),
+                // Similarly, ignore Enter/Escape in "Other" textbox, so that they don't submit/close the form.
+                dom.onKeyDown({
+                  Enter: (ev, elem) => elem.blur(),
+                  Escape: (ev, elem) => elem.blur(),
+                }),
+              )
+            ]
+          ),
+          testId('questions-use-case'),
+        )),
+      ),
+    ),
+    cssContinue(
+      bigPrimaryButton(
+        t('Next step'),
+        dom.show(isFilled),
+        dom.on('click', () => incrementStep()),
+        testId('next-step'),
+      ),
+      bigBasicButton(
+        t('Skip step'),
+        dom.hide(isFilled),
+        dom.on('click', () => incrementStep()),
+        testId('skip-step'),
+      ),
+    ),
+    testId('questions'),
+  );
+}
+
+function buildVideo(_owner: IDisposableOwner, incrementStep: IncrementStep, state: VideoState) {
+  const {watched} = state;
+
+  function onPlay() {
+    watched.set(true);
+
+    return modal((ctl, modalOwner) => {
+      const youtubePlayer = YouTubePlayer.create(modalOwner,
+        ONBOARDING_VIDEO_YOUTUBE_EMBED_ID,
+        {
+          onPlayerReady: (player) => player.playVideo(),
+          onPlayerStateChange(_player, {data}) {
+            if (data !== PlayerState.Ended) { return; }
+
+            ctl.close();
+          },
+          height: '100%',
+          width: '100%',
+          origin: getMainOrgUrl(),
+        },
+        cssYouTubePlayer.cls(''),
+      );
+
+      return [
+        dom.on('click', () => ctl.close()),
+        elem => { FocusLayer.create(modalOwner, {defaultFocusElem: elem, pauseMousetrap: true}); },
+        dom.onKeyDown({
+          Escape: () => ctl.close(),
+          ' ': () => youtubePlayer.playPause(),
+        }),
+        cssModalHeader(
+          cssModalCloseButton(
+            cssCloseIcon('CrossBig'),
+          ),
+        ),
+        cssModalBody(
+          cssVideoPlayer(
+            dom.on('click', (ev) => ev.stopPropagation()),
+            youtubePlayer.buildDom(),
+            testId('video-player'),
+          ),
+          cssModalButtons(
+            bigPrimaryButton(
+              t('Next step'),
+              dom.on('click', (ev) => {
+                ev.stopPropagation();
+                ctl.close();
+                incrementStep();
+              }),
+            ),
+          ),
+        ),
+        cssVideoPlayerModal.cls(''),
+      ];
+    });
+  }
+
+  return dom('div',
+    cssHeading(t('Discover Grist in 3 minutes')),
+    cssScreenshot(
+      dom.on('click', onPlay),
+      dom('div',
+        cssScreenshotImg({src: 'img/youtube-screenshot.png'}),
+        cssActionOverlay(
+          cssAction(
+            cssRoundButton(cssVideoPlayIcon('VideoPlay')),
+          ),
+        ),
+      ),
+      testId('video-thumbnail'),
+    ),
+    cssContinue(
+      cssBackButton(
+        t('Back'),
+        dom.on('click', () => incrementStep(-1)),
+        testId('back'),
+      ),
+      bigPrimaryButton(
+        t('Next step'),
+        dom.show(watched),
+        dom.on('click', () => incrementStep()),
+        testId('next-step'),
+      ),
+      bigBasicButton(
+        t('Skip step'),
+        dom.hide(watched),
+        dom.on('click', () => incrementStep()),
+        testId('skip-step'),
+      ),
+    ),
+    testId('video'),
+  );
+}
+
+function buildTutorial(_owner: IDisposableOwner, incrementStep: IncrementStep) {
+  const {templateOrg, onboardingTutorialDocId} = getGristConfig();
+  return dom('div',
+    cssHeading(
+      t('Go hands-on with the Grist Basics tutorial'),
+      cssSubHeading(
+        t("Grist may look like a spreadsheet, but it doesn't always "
+          + "act like one. Discover what makes Grist different."
+        ),
+      ),
+    ),
+    cssTutorial(
+      cssScreenshot(
+        dom.on('click', () => urlState().pushUrl({org: templateOrg!, doc: onboardingTutorialDocId})),
+        cssTutorialScreenshotImg({src: 'img/tutorial-screenshot.png'}),
+        cssTutorialOverlay(
+          cssAction(
+            cssTutorialButton(t('Go to the tutorial!')),
+          ),
+        ),
+        testId('tutorial-thumbnail'),
+      ),
+    ),
+    cssContinue(
+      cssBackButton(
+        t('Back'),
+        dom.on('click', () => incrementStep(-1)),
+        testId('back'),
+      ),
+      bigBasicButton(
+        t('Skip tutorial'),
+        dom.on('click', () => window.location.href = urlState().makeUrl(urlState().state.get())),
+        testId('skip-tutorial'),
+      ),
+    ),
+    testId('tutorial'),
+  );
+}
+
+const cssPageContainer = styled('div', `
+  overflow: auto;
+  height: 100%;
+  background-color: ${theme.mainPanelBg};
+`);
+
+const cssOnboardingPage = styled('div', `
+  display: flex;
+  min-height: 100%;
+`);
+
+const cssSidebar = styled('div', `
+  width: 460px;
+  background-color: ${colors.lightGreen};
+  color: ${colors.light};
+  background-image:
+    linear-gradient(to bottom, rgb(41, 185, 131) 32px, transparent 32px),
+    linear-gradient(to right, rgb(41, 185, 131) 32px, transparent 32px);
+  background-size: 240px 120px;
+  background-position: 0 0, 40%;
+  display: flex;
+  flex-direction: column;
+
+  @media ${mediaMedium} {
+    & {
+      display: none;
+    }
+  }
+`);
+
+const cssGetStarted = styled('div', `
+  width: 500px;
+  height: 350px;
+  margin: auto -77px 0 37px;
+  overflow: hidden;
+`);
+
+const cssGetStartedImg = styled('img', `
+  display: block;
+  width: 500px;
+  height: auto;
+`);
+
+const cssSidebarContent = styled('div', `
+  line-height: 32px;
+  margin: 112px 16px 64px 16px;
+  font-size: 24px;
+  line-height: 48px;
+  font-weight: 500;
+`);
+
+const cssSidebarHeading1 = styled('div', `
+  font-size: 32px;
+  text-align: center;
+`);
+
+const cssSidebarHeading2 = styled('div', `
+  font-size: 28px;
+  text-align: center;
+`);
+
+const cssMainPanel = styled('div', `
+  margin: 56px auto;
+  padding: 0px 96px;
+  text-align: center;
+
+  @media ${mediaMedium} {
+    & {
+      padding: 0px 32px;
+    }
+  }
+`);
+
+const cssHeading = styled('div', `
+  color: ${theme.text};
+  font-size: 24px;
+  font-weight: 500;
+  margin: 32px 0px;
+`);
+
+const cssSubHeading = styled(cssHeading, `
+  font-size: 15px;
+  font-weight: 400;
+  margin-top: 16px;
+`);
+
+const cssStep = styled('div', `
+  display: flex;
+  align-items: center;
+  cursor: default;
+
+  &:not(:last-child)::after {
+    content: "";
+    width: 50px;
+    height: 2px;
+    background-color: var(--grist-color-light-green);
+  }
+`);
+
+const cssStepCircle = styled('div', `
+  --icon-color: ${theme.controlPrimaryFg};
+  --step-color: ${theme.controlPrimaryBg};
+  display: inline-block;
+  width: 24px;
+  height: 24px;
+  border-radius: 30px;
+  border: 1px solid var(--step-color);
+  color: var(--step-color);
+  margin: 4px;
+  position: relative;
+  cursor: pointer;
+
+  &:hover {
+    --step-color: ${theme.controlPrimaryHoverBg};
+  }
+  &-current {
+    background-color: var(--step-color);
+    color: ${theme.controlPrimaryFg};
+    outline: 3px solid ${theme.cursorInactive};
+  }
+  &-done {
+    background-color: var(--step-color);
+  }
+`);
+
+const cssQuestions = styled('div', `
+  max-width: 500px;
+`);
+
+const cssQuestion = styled('div', `
+  margin: 16px 0 8px 0;
+  text-align: left;
+`);
+
+const cssFieldHeading = styled('div', `
+  color: ${theme.text};
+  font-size: 13px;
+  font-weight: 700;
+  margin-bottom: 12px;
+`);
+
+const cssContinue = styled('div', `
+  display: flex;
+  justify-content: center;
+  margin-top: 40px;
+  gap: 16px;
+`);
+
+const cssUseCases = styled('div', `
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  margin: -8px -4px;
+`);
+
+const cssUseCase = styled('div', `
+  flex: 1 0 40%;
+  min-width: 200px;
+  margin: 8px 4px 0 4px;
+  height: 40px;
+  border: 1px solid ${theme.inputBorder};
+  border-radius: 3px;
+  display: flex;
+  align-items: center;
+  text-align: left;
+  cursor: pointer;
+  color: ${theme.text};
+  --icon-color: ${theme.accentIcon};
+
+  &:hover {
+    background-color: ${theme.hover};
+  }
+  &-selected {
+    border: 2px solid ${theme.controlFg};
+  }
+  &-selected:hover {
+    border: 2px solid ${theme.controlHoverFg};
+  }
+  &-selected:focus-within {
+    box-shadow: 0 0 2px 0px ${theme.controlFg};
+  }
+`);
+
+const cssUseCaseIcon = styled('div', `
+  margin: 0 16px;
+  --icon-color: ${theme.accentIcon};
+`);
+
+const cssOtherLabel = styled('div', `
+  display: block;
+
+  .${cssUseCase.className}-selected & {
+    display: none;
+  }
+`);
+
+const cssInput = styled(textInput, `
+  height: 40px;
+`);
+
+const cssOtherInput = styled(input, `
+  color: ${theme.inputFg};
+  display: none;
+  border: none;
+  background: none;
+  outline: none;
+  padding: 0px;
+
+  &::placeholder {
+    color: ${theme.inputPlaceholderFg};
+  }
+  .${cssUseCase.className}-selected & {
+    display: block;
+  }
+`);
+
+const cssTutorial = styled('div', `
+  display: flex;
+  justify-content: center;
+`);
+
+const cssScreenshot = styled('div', `
+  max-width: 720px;
+  display: flex;
+  position: relative;
+  border-radius: 3px;
+  border: 3px solid ${colors.lightGreen};
+  overflow: hidden;
+  cursor: pointer;
+`);
+
+const cssActionOverlay = styled('div', `
+  position: absolute;
+  z-index: 1;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  background-color: rgba(0, 0, 0, 0.20);
+`);
+
+const cssTutorialOverlay = styled(cssActionOverlay, `
+  background-color: transparent;
+`);
+
+const cssAction = styled('div', `
+  display: flex;
+  flex-direction: column;
+  margin: auto;
+  align-items: center;
+  justify-content: center;
+  height: 100%;
+`);
+
+const cssVideoPlayIcon = styled(icon, `
+  --icon-color: ${colors.light};
+  width: 38px;
+  height: 33.25px;
+`);
+
+const cssCloseIcon = styled(icon, `
+  --icon-color: ${colors.light};
+  width: 22px;
+  height: 22px;
+`);
+
+const cssYouTubePlayer = styled('iframe', `
+  border-radius: 4px;
+`);
+
+const cssModalHeader = styled('div', `
+  display: flex;
+  flex-shrink: 0;
+  justify-content: flex-end;
+`);
+
+const cssModalBody = styled('div', `
+  display: flex;
+  flex-grow: 1;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+`);
+
+const cssBackButton = styled(bigBasicButton, `
+  border: none;
+`);
+
+const cssModalButtons = styled('div', `
+  display: flex;
+  justify-content: center;
+  margin-top: 24px;
+`);
+
+const cssVideoPlayer = styled('div', `
+  width: 100%;
+  max-width: 1280px;
+  height: 100%;
+  max-height: 720px;
+
+  @media ${mediaXSmall} {
+    & {
+      max-height: 240px;
+    }
+  }
+`);
+
+const cssVideoPlayerModal = styled('div', `
+  display: flex;
+  flex-direction: column;
+  width: 100%;
+  height: 100%;
+  padding: 8px;
+  background-color: transparent;
+  box-shadow: none;
+`);
+
+const cssModalCloseButton = styled('div', `
+  margin-bottom: 8px;
+  padding: 4px;
+  border-radius: 4px;
+  cursor: pointer;
+
+  &:hover {
+    background-color: ${theme.hover};
+  }
+`);
+
+const cssScreenshotImg = styled('img', `
+  transform: scale(1.2);
+  width: 100%;
+`);
+
+const cssTutorialScreenshotImg = styled('img', `
+  width: 100%;
+  opacity: 0.4;
+`);
+
+const cssRoundButton = styled('div', `
+  width: 75px;
+  height: 75px;
+  flex-shrink: 0;
+  border-radius: 100px;
+  background: ${colors.lightGreen};
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  --icon-color: var(--light, #FFF);
+
+  .${cssScreenshot.className}:hover & {
+    background: ${colors.darkGreen};
+  }
+`);
+
+const cssStepper = styled('div', `
+  display: flex;
+  justify-content: center;
+  text-align: center;
+  font-size: 14px;
+  font-style: normal;
+  font-weight: 700;
+  line-height: 20px;
+  text-transform: uppercase;
+`);
+
+const cssTutorialButton = styled(bigPrimaryButtonLink, `
+  .${cssScreenshot.className}:hover & {
+    background-color: ${theme.controlPrimaryHoverBg};
+    border-color: ${theme.controlPrimaryHoverBg};
+  }
+`);
diff --git a/app/client/ui/OpenVideoTour.ts b/app/client/ui/OpenVideoTour.ts
index 00a44394..8bd230d3 100644
--- a/app/client/ui/OpenVideoTour.ts
+++ b/app/client/ui/OpenVideoTour.ts
@@ -1,4 +1,3 @@
-import * as commands from 'app/client/components/commands';
 import {makeT} from 'app/client/lib/localization';
 import {logTelemetryEvent} from 'app/client/lib/telemetry';
 import {getMainOrgUrl} from 'app/client/models/gristUrlState';
@@ -7,15 +6,13 @@ import {YouTubePlayer} from 'app/client/ui/YouTubePlayer';
 import {theme} from 'app/client/ui2018/cssVars';
 import {icon} from 'app/client/ui2018/icons';
 import {cssModalCloseButton, modal} from 'app/client/ui2018/modals';
-import {isFeatureEnabled} from 'app/common/gristUrls';
-import {dom, makeTestId, styled} from 'grainjs';
+import {isFeatureEnabled, ONBOARDING_VIDEO_YOUTUBE_EMBED_ID} from 'app/common/gristUrls';
+import {dom, keyframes, makeTestId, styled} from 'grainjs';
 
 const t = makeT('OpenVideoTour');
 
 const testId = makeTestId('test-video-tour-');
 
-const VIDEO_TOUR_YOUTUBE_EMBED_ID = '56AieR9rpww';
-
 /**
  * Opens a modal containing a video tour of Grist.
  */
@@ -23,7 +20,7 @@ const VIDEO_TOUR_YOUTUBE_EMBED_ID = '56AieR9rpww';
   return modal(
     (ctl, owner) => {
       const youtubePlayer = YouTubePlayer.create(owner,
-        VIDEO_TOUR_YOUTUBE_EMBED_ID,
+        ONBOARDING_VIDEO_YOUTUBE_EMBED_ID,
         {
           onPlayerReady: (player) => player.playVideo(),
           height: '100%',
@@ -83,12 +80,7 @@ export function createVideoTourToolsButton(): HTMLDivElement | null {
 
   let iconElement: HTMLElement;
 
-  const commandsGroup = commands.createGroup({
-    videoTourToolsOpen: () => openVideoTour(iconElement),
-  }, null, true);
-
   return cssPageEntryMain(
-    dom.autoDispose(commandsGroup),
     cssPageLink(
       iconElement = cssPageIcon('Video'),
       cssLinkText(t("Video Tour")),
@@ -108,10 +100,19 @@ const cssModal = styled('div', `
   max-width: 864px;
 `);
 
+const delayedVisibility = keyframes(`
+  to {
+    visibility: visible;
+  }
+`);
+
 const cssYouTubePlayerContainer = styled('div', `
   position: relative;
   padding-bottom: 56.25%;
   height: 0;
+  /* Wait until the modal is finished animating. */
+  visibility: hidden;
+  animation: 0s linear 0.4s forwards ${delayedVisibility};
 `);
 
 const cssYouTubePlayer = styled('div', `
diff --git a/app/client/ui/TutorialCard.ts b/app/client/ui/TutorialCard.ts
deleted file mode 100644
index 538ded32..00000000
--- a/app/client/ui/TutorialCard.ts
+++ /dev/null
@@ -1,217 +0,0 @@
-import {AppModel} from 'app/client/models/AppModel';
-import {bigPrimaryButton} from 'app/client/ui2018/buttons';
-import {isNarrowScreenObs, theme} from 'app/client/ui2018/cssVars';
-import {icon} from 'app/client/ui2018/icons';
-import {commonUrls, isFeatureEnabled} from 'app/common/gristUrls';
-import {Computed, dom, IDisposableOwner, makeTestId, styled} from 'grainjs';
-
-const testId = makeTestId('test-tutorial-card-');
-
-interface Options {
-  app: AppModel,
-}
-
-export function buildTutorialCard(owner: IDisposableOwner, options: Options) {
-  if (!isFeatureEnabled('tutorials')) { return null; }
-
-  const {app} = options;
-  function onClose() {
-    app.dismissPopup('tutorialFirstCard', true);
-  }
-  const visible = Computed.create(owner, (use) =>
-       !use(app.dismissedPopups).includes('tutorialFirstCard')
-    && !use(isNarrowScreenObs())
-  );
-  return dom.maybe(visible, () => {
-    return cssCard(
-      cssCaption(
-        dom('div', cssNewToGrist("New to Grist?")),
-        cssRelative(
-          cssStartHere("Start here."),
-          cssArrow()
-        ),
-      ),
-      cssContent(
-        testId('content'),
-        cssImage({src: commonUrls.basicTutorialImage}),
-        cssCardText(
-          cssLine(cssTitle("Grist Basics Tutorial")),
-          cssLine("Learn the basics of reference columns, linked widgets, column types, & cards."),
-          cssLine(cssSub('Beginner - 10 mins')),
-          cssButtonWrapper(
-            cssButtonWrapper.cls('-small'),
-            cssHeroButton("Start Tutorial"),
-            {href: commonUrls.basicTutorial, target: '_blank'},
-          ),
-        ),
-      ),
-      cssButtonWrapper(
-        cssButtonWrapper.cls('-big'),
-        cssHeroButton("Start Tutorial"),
-        {href: commonUrls.basicTutorial, target: '_blank'},
-      ),
-      cssCloseButton(icon('CrossBig'), dom.on('click', () => onClose?.()), testId('close')),
-    );
-  });
-}
-
-const cssContent = styled('div', `
-  position: relative;
-  display: flex;
-  align-items: flex-start;
-  padding-top: 24px;
-  padding-bottom: 20px;
-  padding-right: 20px;
-  max-width: 460px;
-`);
-
-const cssCardText = styled('div', `
-  display: flex;
-  flex-direction: column;
-  justify-content: center;
-  align-self: stretch;
-  margin-left: 12px;
-`);
-
-const cssRelative = styled('div', `
-  position: relative;
-`);
-
-const cssNewToGrist = styled('span', `
-  font-style: normal;
-  font-weight: 400;
-  font-size: 24px;
-  line-height: 16px;
-  letter-spacing: 0.2px;
-  white-space: nowrap;
-`);
-
-const cssStartHere = styled('span', `
-  font-style: normal;
-  font-weight: 700;
-  font-size: 24px;
-  line-height: 16px;
-  letter-spacing: 0.2px;
-  white-space: nowrap;
-`);
-
-const cssCaption = styled('div', `
-  display: flex;
-  flex-direction: column;
-  gap: 12px;
-  margin-left: 32px;
-  margin-top: 42px;
-  margin-right: 64px;
-`);
-
-const cssTitle = styled('span', `
-  font-weight: 600;
-  font-size: 20px;
-`);
-
-const cssSub = styled('span', `
-  font-size: 12px;
-  color: ${theme.lightText};
-`);
-
-const cssLine = styled('div', `
-  margin-bottom: 6px;
-`);
-
-const cssHeroButton = styled(bigPrimaryButton, `
-`);
-
-const cssButtonWrapper = styled('a', `
-  flex-grow: 1;
-  display: flex;
-  justify-content: flex-end;
-  margin-right: 60px;
-  align-items: center;
-  text-decoration: none;
-  &:hover {
-    text-decoration: none;
-  }
-  &-big .${cssHeroButton.className} {
-    padding: 16px 28px;
-    font-weight: 600;
-    font-size: 20px;
-    line-height: 1em;
-  }
-`);
-
-const cssCloseButton = styled('div', `
-  flex-shrink: 0;
-  align-self: flex-end;
-  cursor: pointer;
-  --icon-color: ${theme.controlSecondaryFg};
-  margin: 8px 8px 4px 0px;
-  padding: 2px;
-  border-radius: 4px;
-  position: absolute;
-  top: 0;
-  right: 0;
-  &:hover {
-    background-color: ${theme.lightHover};
-  }
-  &:active {
-    background-color: ${theme.hover};
-  }
-`);
-
-const cssImage = styled('img', `
-  width: 187px;
-  height: 145px;
-  flex: none;
-`);
-
-const cssArrow = styled('div', `
-  position: absolute;
-  background-image: var(--icon-GreenArrow);
-  width: 94px;
-  height: 12px;
-  top: calc(50% - 6px);
-  left: calc(100% - 12px);
-  z-index: 1;
-`);
-
-
-const cssCard = styled('div', `
-  display: flex;
-  position: relative;
-  color: ${theme.text};
-  border-radius: 3px;
-  margin-bottom: 24px;
-  max-width: 1000px;
-  box-shadow: 0 2px 18px 0 ${theme.modalInnerShadow}, 0 0 1px 0 ${theme.modalOuterShadow};
-  & .${cssButtonWrapper.className}-small {
-    display: none;
-  }
-  @media (max-width: 1320px) {
-    & .${cssButtonWrapper.className}-small {
-      flex-direction: column;
-      display: flex;
-      margin-top: 14px;
-      align-self: flex-start;
-    }
-    & .${cssButtonWrapper.className}-big {
-      display: none;
-    }
-  }
-  @media (max-width: 1000px) {
-    & .${cssArrow.className} {
-      display: none;
-    }
-    & .${cssCaption.className} {
-      flex-direction: row;
-      margin-bottom: 24px;
-    }
-    & {
-      flex-direction: column;
-    }
-    & .${cssContent.className} {
-      padding: 12px;
-      max-width: 100%;
-      margin-bottom: 28px;
-    }
-  }
-`);
diff --git a/app/client/ui/WelcomeQuestions.ts b/app/client/ui/WelcomeQuestions.ts
deleted file mode 100644
index fdd752a3..00000000
--- a/app/client/ui/WelcomeQuestions.ts
+++ /dev/null
@@ -1,176 +0,0 @@
-import {makeT} from 'app/client/lib/localization';
-import * as commands from 'app/client/components/commands';
-import {getUserPrefObs} from 'app/client/models/UserPrefs';
-import {colors, testId} from 'app/client/ui2018/cssVars';
-import {icon} from 'app/client/ui2018/icons';
-import {IconName} from 'app/client/ui2018/IconList';
-import {ISaveModalOptions, saveModal} from 'app/client/ui2018/modals';
-import {BaseAPI} from 'app/common/BaseAPI';
-import {UserPrefs} from 'app/common/Prefs';
-import {getGristConfig} from 'app/common/urlUtils';
-import {dom, input, Observable, styled, subscribeElem} from 'grainjs';
-
-const t = makeT('WelcomeQuestions');
-
-export function shouldShowWelcomeQuestions(userPrefsObs: Observable<UserPrefs>): boolean {
-  return Boolean(getGristConfig().survey && userPrefsObs.get()?.showNewUserQuestions);
-}
-
-/**
- * Shows a modal with welcome questions if surveying is enabled and the user hasn't
- * dismissed the modal before.
- */
-export function showWelcomeQuestions(userPrefsObs: Observable<UserPrefs>) {
-  saveModal((ctl, owner): ISaveModalOptions => {
-    const selection = choices.map(c => Observable.create(owner, false));
-    const otherText = Observable.create(owner, '');
-    const showQuestions = getUserPrefObs(userPrefsObs, 'showNewUserQuestions');
-
-    async function onConfirm() {
-      const use_cases = choices.filter((c, i) => selection[i].get()).map(c => c.textKey);
-      const use_other = use_cases.includes("Other") ? otherText.get() : '';
-
-      const submitUrl = new URL(window.location.href);
-      submitUrl.pathname = '/welcome/info';
-      return BaseAPI.request(submitUrl.href,
-        {method: 'POST', body: JSON.stringify({use_cases, use_other})});
-    }
-
-
-    owner.onDispose(async () => {
-      // Whichever way the modal is closed, don't show the questions again. (We set the value to
-      // undefined to remove it from the JSON prefs object entirely; it's never used again.)
-      showQuestions.set(undefined);
-
-      // Show the Grist video tour when the modal is closed.
-      await commands.allCommands.leftPanelOpen.run();
-      commands.allCommands.videoTourToolsOpen.run();
-    });
-
-    return {
-      title: [cssLogo(), dom('div', t("Welcome to Grist!"))],
-      body: buildInfoForm(selection, otherText),
-      saveLabel: 'Start using Grist',
-      saveFunc: onConfirm,
-      hideCancel: true,
-      width: 'fixed-wide',
-      modalArgs: cssModalCentered.cls(''),
-    };
-  });
-}
-
-const choices: Array<{icon: IconName, color: string, textKey: string}> = [
-  {icon: 'UseProduct', color: `${colors.lightGreen}`, textKey: 'Product Development'  },
-  {icon: 'UseFinance', color: '#0075A2',              textKey: 'Finance & Accounting' },
-  {icon: 'UseMedia',   color: '#F7B32B',              textKey: 'Media Production'     },
-  {icon: 'UseMonitor', color: '#F2545B',              textKey: 'IT & Technology'      },
-  {icon: 'UseChart',   color: '#7141F9',              textKey: 'Marketing'            },
-  {icon: 'UseScience', color: '#231942',              textKey: 'Research'             },
-  {icon: 'UseSales',   color: '#885A5A',              textKey: 'Sales'                },
-  {icon: 'UseEducate', color: '#4A5899',              textKey: 'Education'            },
-  {icon: 'UseHr',      color: '#688047',              textKey: 'HR & Management'      },
-  {icon: 'UseOther',   color: '#929299',              textKey: 'Other'                },
-];
-
-function buildInfoForm(selection: Observable<boolean>[], otherText: Observable<string>) {
-  return [
-    dom('span', t("What brings you to Grist? Please help us serve you better.")),
-    cssChoices(
-      choices.map((item, i) => cssChoice(
-        cssIcon(icon(item.icon), {style: `--icon-color: ${item.color}`}),
-        cssChoice.cls('-selected', selection[i]),
-        dom.on('click', () => selection[i].set(!selection[i].get())),
-        (item.icon !== 'UseOther' ?
-          t(item.textKey) :
-          [
-            cssOtherLabel(t(item.textKey)),
-            cssOtherInput(otherText, {}, {type: 'text', placeholder: t("Type here")},
-              // The following subscribes to changes to selection observable, and focuses the input when
-              // this item is selected.
-              (elem) => subscribeElem(elem, selection[i], val => val && setTimeout(() => elem.focus(), 0)),
-              // It's annoying if clicking into the input toggles selection; better to turn that
-              // off (user can click icon to deselect).
-              dom.on('click', ev => ev.stopPropagation()),
-              // Similarly, ignore Enter/Escape in "Other" textbox, so that they don't submit/close the form.
-              dom.onKeyDown({
-                Enter: (ev, elem) => elem.blur(),
-                Escape: (ev, elem) => elem.blur(),
-              }),
-            )
-          ]
-        )
-      )),
-      testId('welcome-questions'),
-    ),
-  ];
-}
-
-const cssModalCentered = styled('div', `
-  text-align: center;
-`);
-
-const cssLogo = styled('div', `
-  display: inline-block;
-  height: 48px;
-  width: 48px;
-  background-image: var(--icon-GristLogo);
-  background-size: 32px 32px;
-  background-repeat: no-repeat;
-  background-position: center;
-`);
-
-const cssChoices = styled('div', `
-  display: flex;
-  flex-wrap: wrap;
-  align-items: center;
-  margin-top: 24px;
-`);
-
-const cssChoice = styled('div', `
-  flex: 1 0 40%;
-  min-width: 0px;
-  margin: 8px 4px 0 4px;
-  height: 40px;
-  border: 1px solid ${colors.darkGrey};
-  border-radius: 3px;
-  display: flex;
-  align-items: center;
-  text-align: left;
-  cursor: pointer;
-
-  &:hover {
-    border-color: ${colors.lightGreen};
-  }
-  &-selected {
-    background-color: ${colors.mediumGrey};
-  }
-  &-selected:hover {
-    border-color: ${colors.darkGreen};
-  }
-  &-selected:focus-within {
-    box-shadow: 0 0 2px 0px var(--grist-color-cursor);
-    border-color: ${colors.lightGreen};
-  }
-`);
-
-const cssIcon = styled('div', `
-  margin: 0 16px;
-`);
-
-const cssOtherLabel = styled('div', `
-  display: block;
-  .${cssChoice.className}-selected & {
-    display: none;
-  }
-`);
-
-const cssOtherInput = styled(input, `
-  display: none;
-  border: none;
-  background: none;
-  outline: none;
-  padding: 0px;
-  .${cssChoice.className}-selected & {
-    display: block;
-  }
-`);
diff --git a/app/client/ui/YouTubePlayer.ts b/app/client/ui/YouTubePlayer.ts
index 10bba6c5..d7db5649 100644
--- a/app/client/ui/YouTubePlayer.ts
+++ b/app/client/ui/YouTubePlayer.ts
@@ -11,6 +11,7 @@ export interface Player {
   unMute(): void;
   setVolume(volume: number): void;
   getCurrentTime(): number;
+  getPlayerState(): PlayerState;
 }
 
 export interface PlayerOptions {
@@ -93,6 +94,18 @@ export class YouTubePlayer extends Disposable {
     this._player.playVideo();
   }
 
+  public pause() {
+    this._player.pauseVideo();
+  }
+
+  public playPause() {
+    if (this._player.getPlayerState() === PlayerState.Playing) {
+      this._player.pauseVideo();
+    } else {
+      this._player.playVideo();
+    }
+  }
+
   public setVolume(volume: number) {
     this._player.setVolume(volume);
   }
diff --git a/app/client/ui2018/IconList.ts b/app/client/ui2018/IconList.ts
index c0fcffbf..d58f39d6 100644
--- a/app/client/ui2018/IconList.ts
+++ b/app/client/ui2018/IconList.ts
@@ -133,13 +133,17 @@ export type IconName = "ChartArea" |
   "Separator" |
   "Settings" |
   "Share" |
+  "Skip" |
   "Sort" |
   "Sparks" |
+  "Star" |
   "Tick" |
   "TickSolid" |
   "Undo" |
   "Validation" |
   "Video" |
+  "VideoPlay" |
+  "VideoPlay2" |
   "Warning" |
   "Widget" |
   "Wrap" |
@@ -290,13 +294,17 @@ export const IconList: IconName[] = ["ChartArea",
   "Separator",
   "Settings",
   "Share",
+  "Skip",
   "Sort",
   "Sparks",
+  "Star",
   "Tick",
   "TickSolid",
   "Undo",
   "Validation",
   "Video",
+  "VideoPlay",
+  "VideoPlay2",
   "Warning",
   "Widget",
   "Wrap",
diff --git a/app/common/Prefs.ts b/app/common/Prefs.ts
index 3c6c3786..0ca63d0f 100644
--- a/app/common/Prefs.ts
+++ b/app/common/Prefs.ts
@@ -107,12 +107,15 @@ export interface BehavioralPromptPrefs {
 export const DismissedPopup = StringUnion(
   'deleteRecords',        // confirmation for deleting records keyboard shortcut
   'deleteFields',         // confirmation for deleting columns keyboard shortcut
-  'tutorialFirstCard',    // first card of the tutorial
   'formulaHelpInfo',      // formula help info shown in the popup editor
   'formulaAssistantInfo', // formula assistant info shown in the popup editor
   'supportGrist',         // nudge to opt in to telemetry
   'publishForm',          // confirmation for publishing a form
   'unpublishForm',        // confirmation for unpublishing a form
+  'onboardingCards',      // onboarding cards shown on the doc menu
+
+  /* Deprecated */
+  'tutorialFirstCard',    // first card of the tutorial
 );
 export type DismissedPopup = typeof DismissedPopup.type;
 
diff --git a/app/common/UserAPI.ts b/app/common/UserAPI.ts
index 9c6e824a..dda2d28b 100644
--- a/app/common/UserAPI.ts
+++ b/app/common/UserAPI.ts
@@ -144,7 +144,7 @@ export interface DocumentOptions {
 
 export interface TutorialMetadata {
   lastSlideIndex?: number;
-  numSlides?: number;
+  percentComplete?: number;
 }
 
 export interface DocumentProperties extends CommonProperties {
@@ -368,6 +368,7 @@ export interface UserAPI {
   getOrgWorkspaces(orgId: number|string, includeSupport?: boolean): Promise<Workspace[]>;
   getOrgUsageSummary(orgId: number|string): Promise<OrgUsageSummary>;
   getTemplates(onlyFeatured?: boolean): Promise<Workspace[]>;
+  getTemplate(docId: string): Promise<Document>;
   getDoc(docId: string): Promise<Document>;
   newOrg(props: Partial<OrganizationProperties>): Promise<number>;
   newWorkspace(props: Partial<WorkspaceProperties>, orgId: number|string): Promise<number>;
@@ -587,6 +588,10 @@ export class UserAPIImpl extends BaseAPI implements UserAPI {
     return this.requestJson(`${this._url}/api/templates?onlyFeatured=${onlyFeatured ? 1 : 0}`, { method: 'GET' });
   }
 
+  public async getTemplate(docId: string): Promise<Document> {
+    return this.requestJson(`${this._url}/api/templates/${docId}`, { method: 'GET' });
+  }
+
   public async getWidgets(): Promise<ICustomWidget[]> {
     return await this.requestJson(`${this._url}/api/widgets`, { method: 'GET' });
   }
diff --git a/app/common/gristUrls.ts b/app/common/gristUrls.ts
index 1f66291e..2f81d215 100644
--- a/app/common/gristUrls.ts
+++ b/app/common/gristUrls.ts
@@ -101,8 +101,6 @@ export const commonUrls = {
   formulas: 'https://support.getgrist.com/formulas',
   forms: 'https://www.getgrist.com/forms/?utm_source=grist-forms&utm_medium=grist-forms&utm_campaign=forms-footer',
 
-  basicTutorial: 'https://templates.getgrist.com/woXtXUBmiN5T/Grist-Basics',
-  basicTutorialImage: 'https://www.getgrist.com/wp-content/uploads/2021/08/lightweight-crm.png',
   gristLabsCustomWidgets: 'https://gristlabs.github.io/grist-widget/',
   gristLabsWidgetRepository: 'https://github.com/gristlabs/grist-widget/releases/download/latest/manifest.json',
   githubGristCore: 'https://github.com/gristlabs/grist-core',
@@ -111,6 +109,8 @@ export const commonUrls = {
   versionCheck: 'https://api.getgrist.com/api/version',
 };
 
+export const ONBOARDING_VIDEO_YOUTUBE_EMBED_ID = '56AieR9rpww';
+
 /**
  * Values representable in a URL. The current state is available as urlState().state observable
  * in client. Updates to this state are expected by functions such as makeUrl() and setLinkUrl().
@@ -811,6 +811,9 @@ export interface GristLoadConfig {
   // The org containing public templates and tutorials.
   templateOrg?: string|null;
 
+  // The doc id of the tutorial shown during onboarding.
+  onboardingTutorialDocId?: string;
+
   // Whether to show the "Delete Account" button in the account page.
   canCloseAccount?: boolean;
 
diff --git a/app/gen-server/ApiServer.ts b/app/gen-server/ApiServer.ts
index b927d62f..7d283f2f 100644
--- a/app/gen-server/ApiServer.ts
+++ b/app/gen-server/ApiServer.ts
@@ -302,6 +302,18 @@ export class ApiServer {
       return sendReply(req, res, query);
     }));
 
+    // GET /api/templates/:did
+    // Get information about a template.
+    this._app.get('/api/templates/:did', expressWrap(async (req, res) => {
+      const templateOrg = getTemplateOrg();
+      if (!templateOrg) {
+        throw new ApiError('Template org is not configured', 501);
+      }
+
+      const query = await this._dbManager.getDoc({...getScope(req), org: templateOrg});
+      return sendOkReply(req, res, query);
+    }));
+
     // GET /api/widgets/
     // Get all widget definitions from external source.
     this._app.get('/api/widgets/', expressWrap(async (req, res) => {
diff --git a/app/gen-server/entity/Document.ts b/app/gen-server/entity/Document.ts
index 44e678ff..cc23265d 100644
--- a/app/gen-server/entity/Document.ts
+++ b/app/gen-server/entity/Document.ts
@@ -134,12 +134,12 @@ export class Document extends Resource {
             this.options.tutorial = null;
           } else {
             this.options.tutorial = this.options.tutorial || {};
-            if (props.options.tutorial.numSlides !== undefined) {
-              this.options.tutorial.numSlides = props.options.tutorial.numSlides;
-            }
             if (props.options.tutorial.lastSlideIndex !== undefined) {
               this.options.tutorial.lastSlideIndex = props.options.tutorial.lastSlideIndex;
             }
+            if (props.options.tutorial.percentComplete !== undefined) {
+              this.options.tutorial.percentComplete = props.options.tutorial.percentComplete;
+            }
           }
         }
         // Normalize so that null equates with absence.
diff --git a/app/server/lib/DocApi.ts b/app/server/lib/DocApi.ts
index f7d0a946..1ceef806 100644
--- a/app/server/lib/DocApi.ts
+++ b/app/server/lib/DocApi.ts
@@ -1124,7 +1124,7 @@ export class DocWorkerApi {
           const scope = getDocScope(req);
           const tutorialTrunkId = options.sourceDocId;
           await this._dbManager.connection.transaction(async (manager) => {
-            // Fetch the tutorial trunk doc so we can replace the tutorial doc's name.
+            // Fetch the tutorial trunk so we can replace the tutorial fork's name.
             const tutorialTrunk = await this._dbManager.getDoc({...scope, urlId: tutorialTrunkId}, manager);
             await this._dbManager.updateDocument(
               scope,
@@ -1132,9 +1132,8 @@ export class DocWorkerApi {
                 name: tutorialTrunk.name,
                 options: {
                   tutorial: {
-                    ...tutorialTrunk.options?.tutorial,
-                    // For now, the only state we need to reset is the slide position.
                     lastSlideIndex: 0,
+                    percentComplete: 0,
                   },
                 },
               },
diff --git a/app/server/lib/FlexServer.ts b/app/server/lib/FlexServer.ts
index 8004e4e2..06e9b855 100644
--- a/app/server/lib/FlexServer.ts
+++ b/app/server/lib/FlexServer.ts
@@ -1055,7 +1055,7 @@ export class FlexServer implements GristServer {
           // Reset isFirstTimeUser flag.
           await this._dbManager.updateUser(user.id, {isFirstTimeUser: false});
 
-          // This is a good time to set some other flags, for showing a popup with welcome question(s)
+          // This is a good time to set some other flags, for showing a page with welcome question(s)
           // to this new user and recording their sign-up with Google Tag Manager. These flags are also
           // scoped to the user, but isFirstTimeUser has a dedicated DB field because it predates userPrefs.
           // Note that the updateOrg() method handles all levels of prefs (for user, user+org, or org).
@@ -1586,20 +1586,25 @@ export class FlexServer implements GristServer {
     this.app.post('/welcome/info', ...middleware, expressWrap(async (req, resp, next) => {
       const userId = getUserId(req);
       const user = getUser(req);
+      const orgName = stringParam(req.body.org_name, 'org_name');
+      const orgRole = stringParam(req.body.org_role, 'org_role');
       const useCases = stringArrayParam(req.body.use_cases, 'use_cases');
       const useOther = stringParam(req.body.use_other, 'use_other');
       const row = {
         UserID: userId,
         Name: user.name,
         Email: user.loginEmail,
+        org_name: orgName,
+        org_role: orgRole,
         use_cases: ['L', ...useCases],
         use_other: useOther,
       };
-      this._recordNewUserInfo(row)
-      .catch(e => {
+      try {
+        await this._recordNewUserInfo(row);
+      } catch (e) {
         // If we failed to record, at least log the data, so we could potentially recover it.
         log.rawWarn(`Failed to record new user info: ${e.message}`, {newUserQuestions: row});
-      });
+      }
       const nonOtherUseCases = useCases.filter(useCase => useCase !== 'Other');
       for (const useCase of [...nonOtherUseCases, ...(useOther ? [`Other - ${useOther}`] : [])]) {
         this.getTelemetry().logEvent(req as RequestWithLogin, 'answeredUseCaseQuestion', {
diff --git a/app/server/lib/gristSettings.ts b/app/server/lib/gristSettings.ts
index 3ffb8197..51a79dfe 100644
--- a/app/server/lib/gristSettings.ts
+++ b/app/server/lib/gristSettings.ts
@@ -11,3 +11,9 @@ export function getTemplateOrg() {
   }
   return org;
 }
+
+export function getOnboardingTutorialDocId() {
+  return appSettings.section('tutorials').flag('onboardingTutorialDocId').readString({
+    envVar: 'GRIST_ONBOARDING_TUTORIAL_DOC_ID',
+  });
+}
diff --git a/app/server/lib/sendAppPage.ts b/app/server/lib/sendAppPage.ts
index 3fce3c38..460137fa 100644
--- a/app/server/lib/sendAppPage.ts
+++ b/app/server/lib/sendAppPage.ts
@@ -16,7 +16,7 @@ import {SUPPORT_EMAIL} from 'app/gen-server/lib/homedb/HomeDBManager';
 import {isAnonymousUser, isSingleUserMode, RequestWithLogin} from 'app/server/lib/Authorizer';
 import {RequestWithOrg} from 'app/server/lib/extractOrg';
 import {GristServer} from 'app/server/lib/GristServer';
-import {getTemplateOrg} from 'app/server/lib/gristSettings';
+import {getOnboardingTutorialDocId, getTemplateOrg} from 'app/server/lib/gristSettings';
 import {getSupportedEngineChoices} from 'app/server/lib/serverUtils';
 import {readLoadedLngs, readLoadedNamespaces} from 'app/server/localization';
 import * as express from 'express';
@@ -97,6 +97,7 @@ export function makeGristConfig(options: MakeGristConfigOptions): GristLoadConfi
     telemetry: server?.getTelemetry().getTelemetryConfig(req as RequestWithLogin | undefined),
     deploymentType: server?.getDeploymentType(),
     templateOrg: getTemplateOrg(),
+    onboardingTutorialDocId: getOnboardingTutorialDocId(),
     canCloseAccount: isAffirmative(process.env.GRIST_ACCOUNT_CLOSE),
     experimentalPlugins: isAffirmative(process.env.GRIST_EXPERIMENTAL_PLUGINS),
     notifierEnabled: server?.hasNotifier(),
diff --git a/static/icons/icons.css b/static/icons/icons.css
index 1a87652f..5159b32d 100644
--- a/static/icons/icons.css
+++ b/static/icons/icons.css
@@ -32,9 +32,9 @@
   --icon-FieldText: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTcuNSwxMyBMNy41LDMgTDMsMyBMMyw0LjUgQzMsNC43NzYxNDIzNyAyLjc3NjE0MjM3LDUgMi41LDUgQzIuMjIzODU3NjMsNSAyLDQuNzc2MTQyMzcgMiw0LjUgTDIsMi41IEMyLDIuMjIzODU3NjMgMi4yMjM4NTc2MywyIDIuNSwyIEwxMy41LDIgQzEzLjc3NjE0MjQsMiAxNCwyLjIyMzg1NzYzIDE0LDIuNSBMMTQsNC41IEMxNCw0Ljc3NjE0MjM3IDEzLjc3NjE0MjQsNSAxMy41LDUgQzEzLjIyMzg1NzYsNSAxMyw0Ljc3NjE0MjM3IDEzLDQuNSBMMTMsMyBMOC41LDMgTDguNSwxMyBMMTAuNSwxMyBDMTAuNzc2MTQyNCwxMyAxMSwxMy4yMjM4NTc2IDExLDEzLjUgQzExLDEzLjc3NjE0MjQgMTAuNzc2MTQyNCwxNCAxMC41LDE0IEw1LjUsMTQgQzUuMjIzODU3NjMsMTQgNSwxMy43NzYxNDI0IDUsMTMuNSBDNSwxMy4yMjM4NTc2IDUuMjIzODU3NjMsMTMgNS41LDEzIEw3LjUsMTMgWiIgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIi8+PC9zdmc+');
   --icon-FieldTextbox: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTIuNSwxMyBDMi4yMjM4NTc2MywxMyAyLDEyLjc3NjE0MjQgMiwxMi41IEMyLDEyLjIyMzg1NzYgMi4yMjM4NTc2MywxMiAyLjUsMTIgTDEzLjUsMTIgQzEzLjc3NjE0MjQsMTIgMTQsMTIuMjIzODU3NiAxNCwxMi41IEMxNCwxMi43NzYxNDI0IDEzLjc3NjE0MjQsMTMgMTMuNSwxMyBMMi41LDEzIFogTTIuNSw0IEMyLjIyMzg1NzYzLDQgMiwzLjc3NjE0MjM3IDIsMy41IEMyLDMuMjIzODU3NjMgMi4yMjM4NTc2MywzIDIuNSwzIEwxMy41LDMgQzEzLjc3NjE0MjQsMyAxNCwzLjIyMzg1NzYzIDE0LDMuNSBDMTQsMy43NzYxNDIzNyAxMy43NzYxNDI0LDQgMTMuNSw0IEwyLjUsNCBaIE0yLjUsOC41IEMyLjIyMzg1NzYzLDguNSAyLDguMjc2MTQyMzcgMiw4IEMyLDcuNzIzODU3NjMgMi4yMjM4NTc2Myw3LjUgMi41LDcuNSBMMTMuNSw3LjUgQzEzLjc3NjE0MjQsNy41IDE0LDcuNzIzODU3NjMgMTQsOCBDMTQsOC4yNzYxNDIzNyAxMy43NzYxNDI0LDguNSAxMy41LDguNSBMMi41LDguNSBaIiBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9Im5vbnplcm8iLz48L3N2Zz4=');
   --icon-FieldToggle: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTguNjQ1ODI0NCwxMSBMMTAsMTEgQzExLjY1Njg1NDIsMTEgMTMsOS42NTY4NTQyNSAxMyw4IEMxMyw2LjM0MzE0NTc1IDExLjY1Njg1NDIsNSAxMCw1IEw4LjY0NTgyNDQsNSBDOS40NzYyNDUxMSw1LjczMjk0NDQ1IDEwLDYuODA1MzA3NDcgMTAsOCBDMTAsOS4xOTQ2OTI1MyA5LjQ3NjI0NTExLDEwLjI2NzA1NTUgOC42NDU4MjQ0LDExIFogTTYsNCBMMTAsNCBDMTIuMjA5MTM5LDQgMTQsNS43OTA4NjEgMTQsOCBDMTQsMTAuMjA5MTM5IDEyLjIwOTEzOSwxMiAxMCwxMiBMNiwxMiBDMy43OTA4NjEsMTIgMiwxMC4yMDkxMzkgMiw4IEMyLDUuNzkwODYxIDMuNzkwODYxLDQgNiw0IFogTTYsMTEgQzcuNjU2ODU0MjUsMTEgOSw5LjY1Njg1NDI1IDksOCBDOSw2LjM0MzE0NTc1IDcuNjU2ODU0MjUsNSA2LDUgQzQuMzQzMTQ1NzUsNSAzLDYuMzQzMTQ1NzUgMyw4IEMzLDkuNjU2ODU0MjUgNC4zNDMxNDU3NSwxMSA2LDExIFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg==');
-  --icon-LoginStreamline: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0zOS41NDAyIDBIMTguMDA0N0M4LjA2NzUyIDAgMCA4Ljk3NzQgMCAyMC4wMzUzVjQwSDIxLjcwNTFDMzEuNTQ4NyA0MCAzOS41NDAyIDMxLjEwNjggMzkuNTQwMiAyMC4xNTMxVjBaIiBmaWxsPSIjRjlBRTQxIiBvcGFjaXR5PSIuMSIvPjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMjIuNDkxNyAyNy45MzA2TDI4LjQ1MyAxOC44MzA3QzI4LjY1MiAxOC41MjY4IDI4LjU3NzYgMTguMTExOSAyOC4yODY2IDE3LjkwNEMyOC4xODA1IDE3LjgyODEgMjguMDU0OCAxNy43ODc1IDI3LjkyNjIgMTcuNzg3NUgyMy4yNzlWMTEuOTk0OEMyMy4yNzkgMTEuNjI2NiAyMi45OTMyIDExLjMyODEgMjIuNjQwNyAxMS4zMjgxQzIyLjQzIDExLjMyODEgMjIuMjMyOSAxMS40MzY3IDIyLjExMzkgMTEuNjE4M0wxNi4xNTI2IDIwLjcxODNDMTUuOTUzNSAyMS4wMjIxIDE2LjAyOCAyMS40MzcgMTYuMzE4OSAyMS42NDQ5QzE2LjQyNTEgMjEuNzIwOCAxNi41NTA3IDIxLjc2MTQgMTYuNjc5NCAyMS43NjE0SDIxLjMyNjZWMjcuNTU0MkMyMS4zMjY2IDI3LjkyMjMgMjEuNjEyMyAyOC4yMjA4IDIxLjk2NDkgMjguMjIwOEMyMi4xNzU2IDI4LjIyMDggMjIuMzcyNyAyOC4xMTIyIDIyLjQ5MTcgMjcuOTMwNloiIGZpbGw9IiNGOUFFNDEiLz48cGF0aCBvcGFjaXR5PSIuNDQiIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMTYuMTQ1MyAyNC43NDE5QzE2Ljg1MDMgMjQuNzQxOSAxNy40MjE5IDI1LjMzODggMTcuNDIxOSAyNi4wNzUyVjI2LjM4ODlDMTcuNDIxOSAyNy4xMjUzIDE2Ljg1MDMgMjcuNzIyMyAxNi4xNDUzIDI3LjcyMjNIMTAuODg4N0MxMC4xODM3IDI3LjcyMjMgOS42MTIxMSAyNy4xMjUzIDkuNjEyMTEgMjYuMzg4OVYyNi4wNzUyQzkuNjEyMTEgMjUuMzM4OCAxMC4xODM3IDI0Ljc0MTkgMTAuODg4NyAyNC43NDE5SDE2LjE0NTNaTTEzLjIxNjYgMTguNzgxMUMxMy45MjE3IDE4Ljc4MTEgMTQuNDkzMiAxOS4zNzgxIDE0LjQ5MzIgMjAuMTE0NFYyMC40MjgyQzE0LjQ5MzIgMjEuMTY0NSAxMy45MjE3IDIxLjc2MTUgMTMuMjE2NiAyMS43NjE1SDguOTM2MjZDOC4yMzEyMiAyMS43NjE1IDcuNjU5NjcgMjEuMTY0NSA3LjY1OTY3IDIwLjQyODJWMjAuMTE0NEM3LjY1OTY3IDE5LjM3ODEgOC4yMzEyMiAxOC43ODExIDguOTM2MjYgMTguNzgxMUgxMy4yMTY2Wk0xNi4xNDUzIDEyLjgyMDNDMTYuODUwMyAxMi44MjAzIDE3LjQyMTkgMTMuNDE3MyAxNy40MjE5IDE0LjE1MzZWMTQuNDY3NEMxNy40MjE5IDE1LjIwMzggMTYuODUwMyAxNS44MDA3IDE2LjE0NTMgMTUuODAwN0gxMC44ODg3QzEwLjE4MzcgMTUuODAwNyA5LjYxMjExIDE1LjIwMzggOS42MTIxMSAxNC40Njc0VjE0LjE1MzZDOS42MTIxMSAxMy40MTczIDEwLjE4MzcgMTIuODIwMyAxMC44ODg3IDEyLjgyMDNIMTYuMTQ1M1oiIGZpbGw9IiNGOUFFNDEiLz48L3N2Zz4=');
-  --icon-LoginUnify: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggb3BhY2l0eT0iLjEiIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMzkuNTQwMiAwSDE4LjAwNDdDOC4wNjc1MiAwIDAgOC45Nzc0IDAgMjAuMDM1M1Y0MEgyMS43MDUxQzMxLjU0ODcgNDAgMzkuNTQwMiAzMS4xMDY4IDM5LjU0MDIgMjAuMTUzMVYwWiIgZmlsbD0iIzcxNDFGOSIvPjxwYXRoIG9wYWNpdHk9Ii40NCIgZD0iTTEzLjgyOTcgMTYuNzc4NlYyMy4yMjNDMTMuODI5NyAyNS4wMDI2IDE1LjIxMSAyNi40NDUyIDE2LjkxNDggMjYuNDQ1MkgyMy4wODUxVjI3LjMyNEMyMy4wODUxIDI4Ljg0MzEgMjIuMjk1OCAyOS42Njc1IDIwLjg0MTMgMjkuNjY3NUgxMi45ODgzQzExLjUzMzkgMjkuNjY3NSAxMC43NDQ2IDI4Ljg0MzEgMTAuNzQ0NiAyNy4zMjRWMTkuMTIyQzEwLjc0NDYgMTcuNjAyOSAxMS41MzM5IDE2Ljc3ODYgMTIuOTg4MyAxNi43Nzg2SDEzLjgyOTdaTTI1Ljk4MzIgMTEuNDA4MkMyNy40Mzc2IDExLjQwODIgMjguMjI2OSAxMi4yMzI2IDI4LjIyNjkgMTMuNzUxNlYyMS45NTM3QzI4LjIyNjkgMjMuNDcyNyAyNy40Mzc2IDI0LjI5NzEgMjUuOTgzMiAyNC4yOTcxSDI1LjE0MThWMTcuODUyNkMyNS4xNDE4IDE2LjA3MzEgMjMuNzYwNSAxNC42MzA0IDIyLjA1NjcgMTQuNjMwNEgxNS44ODY1VjEzLjc1MTZDMTUuODg2NSAxMi4yMzI2IDE2LjY3NTggMTEuNDA4MiAxOC4xMzAyIDExLjQwODJIMjUuOTgzMloiIGZpbGw9IiM3MTQxRjkiLz48cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTE3LjE5NTYgMTYuNzc5M0gyMS43NzY1QzIyLjYyNDkgMTYuNzc5MyAyMy4wODUzIDE3LjI2MDIgMjMuMDg1MyAxOC4xNDYzVjIyLjkzMDhDMjMuMDg1MyAyMy44MTY5IDIyLjYyNDkgMjQuMjk3OCAyMS43NzY1IDI0LjI5NzhIMTcuMTk1NkMxNi4zNDcxIDI0LjI5NzggMTUuODg2NyAyMy44MTY5IDE1Ljg4NjcgMjIuOTMwOFYxOC4xNDYzQzE1Ljg4NjcgMTcuMjYwMiAxNi4zNDcxIDE2Ljc3OTMgMTcuMTk1NiAxNi43NzkzWiIgZmlsbD0iIzcxNDFGOSIvPjwvc3ZnPg==');
-  --icon-LoginVisualize: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggb3BhY2l0eT0iLjEiIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMzkuNTQwMiAwSDE4LjAwNDdDOC4wNjc1MiAwIDAgOC45Nzc0IDAgMjAuMDM1M1Y0MEgyMS43MDUxQzMxLjU0ODcgNDAgMzkuNTQwMiAzMS4xMDY4IDM5LjU0MDIgMjAuMTUzMVYwWiIgZmlsbD0iIzE2QjM3OCIvPjxwYXRoIG9wYWNpdHk9Ii40NCIgZD0iTTIzLjk3MTggMTIuMjIxNkMyMy45NzE4IDExLjM2MjUgMjMuMzA1IDEwLjY2NiAyMi40ODI0IDEwLjY2NkMyMS42NTk5IDEwLjY2NiAyMC45OTMgMTEuMzYyNSAyMC45OTMgMTIuMjIxNlYyNi43NDAxQzIwLjk5MyAyNy41OTkyIDIxLjY1OTkgMjguMjk1NiAyMi40ODI0IDI4LjI5NTZDMjMuMzA1IDI4LjI5NTYgMjMuOTcxOCAyNy41OTkyIDIzLjk3MTggMjYuNzQwMVYxMi4yMjE2WiIgZmlsbD0iIzE2QjM3OCIvPjxwYXRoIGQ9Ik0xOS4wMDcgMTcuNzMwNEMxOS4wMDcgMTYuODcxMyAxOC4zNDAyIDE2LjE3NDggMTcuNTE3NyAxNi4xNzQ4IDE2LjY5NTEgMTYuMTc0OCAxNi4wMjgzIDE2Ljg3MTMgMTYuMDI4MyAxNy43MzA0VjI2LjczOTZDMTYuMDI4MyAyNy41OTg3IDE2LjY5NTEgMjguMjk1MiAxNy41MTc3IDI4LjI5NTIgMTguMzQwMiAyOC4yOTUyIDE5LjAwNyAyNy41OTg3IDE5LjAwNyAyNi43Mzk2VjE3LjczMDR6TTI4LjkzNjIgMTkuOTM0NUMyOC45MzYyIDE5LjA3NTQgMjguMjY5NCAxOC4zNzg5IDI3LjQ0NjkgMTguMzc4OSAyNi42MjQzIDE4LjM3ODkgMjUuOTU3NSAxOS4wNzU0IDI1Ljk1NzUgMTkuOTM0NVYyNi43NEMyNS45NTc1IDI3LjU5OTEgMjYuNjI0MyAyOC4yOTU2IDI3LjQ0NjkgMjguMjk1NiAyOC4yNjk0IDI4LjI5NTYgMjguOTM2MiAyNy41OTkxIDI4LjkzNjIgMjYuNzRWMTkuOTM0NXpNMTQuMDQyNyAyMi4xMzg2QzE0LjA0MjcgMjEuMjc5NSAxMy4zNzU5IDIwLjU4MyAxMi41NTMzIDIwLjU4MyAxMS43MzA4IDIwLjU4MyAxMS4wNjQgMjEuMjc5NSAxMS4wNjQgMjIuMTM4NlYyNi43NDA0QzExLjA2NCAyNy41OTk1IDExLjczMDggMjguMjk2IDEyLjU1MzMgMjguMjk2IDEzLjM3NTkgMjguMjk2IDE0LjA0MjcgMjcuNTk5NSAxNC4wNDI3IDI2Ljc0MDRWMjIuMTM4NnoiIGZpbGw9IiMxNkIzNzgiLz48L3N2Zz4=');
+  --icon-LoginStreamline: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgY2xpcC1wYXRoPSJ1cmwoI2NsaXAwXzEzNDlfNjg4NSkiIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIj48cGF0aCBkPSJNMzkuNTQwMiAwSDE4LjAwNDdDOC4wNjc1MiAwIDAgOC45Nzc0IDAgMjAuMDM1M1Y0MEgyMS43MDUxQzMxLjU0ODcgNDAgMzkuNTQwMiAzMS4xMDY4IDM5LjU0MDIgMjAuMTUzMVYwWiIgZmlsbD0iI2ZmZiIvPjxwYXRoIGQ9Ik0yMy40OTE3IDI3LjkzMDZMMjkuNDUzIDE4LjgzMDdDMjkuNjUyIDE4LjUyNjggMjkuNTc3NiAxOC4xMTE5IDI5LjI4NjYgMTcuOTA0QzI5LjE4MDUgMTcuODI4MSAyOS4wNTQ4IDE3Ljc4NzUgMjguOTI2MiAxNy43ODc1SDI0LjI3OVYxMS45OTQ4QzI0LjI3OSAxMS42MjY2IDIzLjk5MzIgMTEuMzI4MSAyMy42NDA3IDExLjMyODFDMjMuNDMgMTEuMzI4MSAyMy4yMzI5IDExLjQzNjcgMjMuMTEzOSAxMS42MTgzTDE3LjE1MjYgMjAuNzE4M0MxNi45NTM1IDIxLjAyMjEgMTcuMDI4IDIxLjQzNyAxNy4zMTg5IDIxLjY0NDlDMTcuNDI1MSAyMS43MjA4IDE3LjU1MDcgMjEuNzYxNCAxNy42Nzk0IDIxLjc2MTRIMjIuMzI2NlYyNy41NTQyQzIyLjMyNjYgMjcuOTIyMyAyMi42MTIzIDI4LjIyMDggMjIuOTY0OSAyOC4yMjA4QzIzLjE3NTYgMjguMjIwOCAyMy4zNzI3IDI4LjExMjIgMjMuNDkxNyAyNy45MzA2WiIgZmlsbD0iI0Y5QUU0MSIvPjxwYXRoIG9wYWNpdHk9Ii40NCIgZD0iTTE3LjE0NTggMjQuNzQxOUMxNy44NTA4IDI0Ljc0MTkgMTguNDIyNCAyNS4zMzg4IDE4LjQyMjQgMjYuMDc1MlYyNi4zODg5QzE4LjQyMjQgMjcuMTI1MyAxNy44NTA4IDI3LjcyMjMgMTcuMTQ1OCAyNy43MjIzSDExLjg4OTJDMTEuMTg0MSAyNy43MjIzIDEwLjYxMjYgMjcuMTI1MyAxMC42MTI2IDI2LjM4ODlWMjYuMDc1MkMxMC42MTI2IDI1LjMzODggMTEuMTg0MSAyNC43NDE5IDExLjg4OTIgMjQuNzQxOUgxNy4xNDU4Wk0xNC4yMTcxIDE4Ljc4MTFDMTQuOTIyMSAxOC43ODExIDE1LjQ5MzcgMTkuMzc4MSAxNS40OTM3IDIwLjExNDRWMjAuNDI4MkMxNS40OTM3IDIxLjE2NDUgMTQuOTIyMSAyMS43NjE1IDE0LjIxNzEgMjEuNzYxNUg5LjkzNjc1QzkuMjMxNzEgMjEuNzYxNSA4LjY2MDE2IDIxLjE2NDUgOC42NjAxNiAyMC40MjgyVjIwLjExNDRDOC42NjAxNiAxOS4zNzgxIDkuMjMxNzEgMTguNzgxMSA5LjkzNjc1IDE4Ljc4MTFIMTQuMjE3MVpNMTcuMTQ1OCAxMi44MjAzQzE3Ljg1MDggMTIuODIwMyAxOC40MjI0IDEzLjQxNzMgMTguNDIyNCAxNC4xNTM2VjE0LjQ2NzRDMTguNDIyNCAxNS4yMDM4IDE3Ljg1MDggMTUuODAwNyAxNy4xNDU4IDE1LjgwMDdIMTEuODg5MkMxMS4xODQxIDE1LjgwMDcgMTAuNjEyNiAxNS4yMDM4IDEwLjYxMjYgMTQuNDY3NFYxNC4xNTM2QzEwLjYxMjYgMTMuNDE3MyAxMS4xODQxIDEyLjgyMDMgMTEuODg5MiAxMi44MjAzSDE3LjE0NThaIiBmaWxsPSIjRjlBRTQxIi8+PC9nPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDBfMTM0OV82ODg1Ij48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMCAwSDQwVjQwSDB6Ii8+PC9jbGlwUGF0aD48L2RlZnM+PC9zdmc+');
+  --icon-LoginUnify: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgY2xpcC1wYXRoPSJ1cmwoI2NsaXAwXzEzNDlfNjg3MSkiPjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMzkuNTQwMiAwSDE4LjAwNDdDOC4wNjc1MiAwIDAgOC45Nzc0IDAgMjAuMDM1M1Y0MEgyMS43MDUxQzMxLjU0ODcgNDAgMzkuNTQwMiAzMS4xMDY4IDM5LjU0MDIgMjAuMTUzMVYwWiIgZmlsbD0iI2ZmZiIvPjxwYXRoIG9wYWNpdHk9Ii40NCIgZD0iTTEzLjgzMTIgMTYuNzc4NlYyMy4yMjNDMTMuODMxMiAyNS4wMDI2IDE1LjIxMjUgMjYuNDQ1MiAxNi45MTYzIDI2LjQ0NTJIMjMuMDg2NVYyNy4zMjRDMjMuMDg2NSAyOC44NDMxIDIyLjI5NzIgMjkuNjY3NSAyMC44NDI4IDI5LjY2NzVIMTIuOTg5OEMxMS41MzU0IDI5LjY2NzUgMTAuNzQ2MSAyOC44NDMxIDEwLjc0NjEgMjcuMzI0VjE5LjEyMkMxMC43NDYxIDE3LjYwMjkgMTEuNTM1NCAxNi43Nzg2IDEyLjk4OTggMTYuNzc4NkgxMy44MzEyWk0yNS45ODQ3IDExLjQwODJDMjcuNDM5MSAxMS40MDgyIDI4LjIyODQgMTIuMjMyNiAyOC4yMjg0IDEzLjc1MTZWMjEuOTUzN0MyOC4yMjg0IDIzLjQ3MjcgMjcuNDM5MSAyNC4yOTcxIDI1Ljk4NDcgMjQuMjk3MUgyNS4xNDMzVjE3Ljg1MjZDMjUuMTQzMyAxNi4wNzMxIDIzLjc2MiAxNC42MzA0IDIyLjA1ODIgMTQuNjMwNEgxNS44ODc5VjEzLjc1MTZDMTUuODg3OSAxMi4yMzI2IDE2LjY3NzIgMTEuNDA4MiAxOC4xMzE3IDExLjQwODJIMjUuOTg0N1oiIGZpbGw9IiM3MTQxRjkiLz48cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTE3LjE5NzUgMTYuNzc5M0gyMS43Nzg0QzIyLjYyNjggMTYuNzc5MyAyMy4wODczIDE3LjI2MDIgMjMuMDg3MyAxOC4xNDYzVjIyLjkzMDhDMjMuMDg3MyAyMy44MTY5IDIyLjYyNjggMjQuMjk3OCAyMS43Nzg0IDI0LjI5NzhIMTcuMTk3NUMxNi4zNDkxIDI0LjI5NzggMTUuODg4NyAyMy44MTY5IDE1Ljg4ODcgMjIuOTMwOFYxOC4xNDYzQzE1Ljg4ODcgMTcuMjYwMiAxNi4zNDkxIDE2Ljc3OTMgMTcuMTk3NSAxNi43NzkzWiIgZmlsbD0iIzcxNDFGOSIvPjwvZz48ZGVmcz48Y2xpcFBhdGggaWQ9ImNsaXAwXzEzNDlfNjg3MSI+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTAgMEg0MFY0MEgweiIvPjwvY2xpcFBhdGg+PC9kZWZzPjwvc3ZnPg==');
+  --icon-LoginVisualize: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgY2xpcC1wYXRoPSJ1cmwoI2NsaXAwXzEzNDlfNjg5OCkiPjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMzkuNTQwMiAwSDE4LjAwNDdDOC4wNjc1MiAwIDAgOC45Nzc0IDAgMjAuMDM1M1Y0MEgyMS43MDUxQzMxLjU0ODcgNDAgMzkuNTQwMiAzMS4xMDY4IDM5LjU0MDIgMjAuMTUzMVYwWiIgZmlsbD0iI2ZmZiIvPjxwYXRoIG9wYWNpdHk9Ii40NCIgZD0iTTIzLjk3MjkgMTIuOTk5NkMyMy45NzI5IDEyLjIyNjQgMjMuMzA2MSAxMS41OTk2IDIyLjQ4MzUgMTEuNTk5NkMyMS42NjEgMTEuNTk5NiAyMC45OTQxIDEyLjIyNjQgMjAuOTk0MSAxMi45OTk2VjI2LjA2NjNDMjAuOTk0MSAyNi44Mzk1IDIxLjY2MSAyNy40NjYzIDIyLjQ4MzUgMjcuNDY2M0MyMy4zMDYxIDI3LjQ2NjMgMjMuOTcyOSAyNi44Mzk1IDIzLjk3MjkgMjYuMDY2M1YxMi45OTk2WiIgZmlsbD0iIzE2QjM3OCIvPjxwYXRoIGQ9Ik0xOS4wMDggMTcuOTU3NkMxOS4wMDggMTcuMTg0NCAxOC4zNDEyIDE2LjU1NzYgMTcuNTE4NyAxNi41NTc2IDE2LjY5NjEgMTYuNTU3NiAxNi4wMjkzIDE3LjE4NDQgMTYuMDI5MyAxNy45NTc2VjI2LjA2NkMxNi4wMjkzIDI2LjgzOTEgMTYuNjk2MSAyNy40NjYgMTcuNTE4NyAyNy40NjYgMTguMzQxMiAyNy40NjYgMTkuMDA4IDI2LjgzOTEgMTkuMDA4IDI2LjA2NlYxNy45NTc2ek0yOC45MzU4IDE5Ljk0MUMyOC45MzU4IDE5LjE2NzggMjguMjY4OSAxOC41NDEgMjcuNDQ2NCAxOC41NDEgMjYuNjIzOCAxOC41NDEgMjUuOTU3IDE5LjE2NzggMjUuOTU3IDE5Ljk0MVYyNi4wNjZDMjUuOTU3IDI2LjgzOTIgMjYuNjIzOCAyNy40NjYgMjcuNDQ2NCAyNy40NjYgMjguMjY4OSAyNy40NjYgMjguOTM1OCAyNi44MzkyIDI4LjkzNTggMjYuMDY2VjE5Ljk0MXpNMTQuMDQzMiAyMS45MjU0QzE0LjA0MzIgMjEuMTUyMiAxMy4zNzY0IDIwLjUyNTQgMTIuNTUzOCAyMC41MjU0IDExLjczMTMgMjAuNTI1NCAxMS4wNjQ1IDIxLjE1MjIgMTEuMDY0NSAyMS45MjU0VjI2LjA2NzFDMTEuMDY0NSAyNi44NDAzIDExLjczMTMgMjcuNDY3MSAxMi41NTM4IDI3LjQ2NzEgMTMuMzc2NCAyNy40NjcxIDE0LjA0MzIgMjYuODQwMyAxNC4wNDMyIDI2LjA2NzFWMjEuOTI1NHoiIGZpbGw9IiMxNkIzNzgiLz48L2c+PGRlZnM+PGNsaXBQYXRoIGlkPSJjbGlwMF8xMzQ5XzY4OTgiPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0wIDBINDBWNDBIMHoiLz48L2NsaXBQYXRoPjwvZGVmcz48L3N2Zz4=');
   --icon-GoogleLogo: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZmlsbD0iIzQyODVGNCIgZD0iTSAtMy4yNjQgNTEuNTA5IEMgLTMuMjY0IDUwLjcxOSAtMy4zMzQgNDkuOTY5IC0zLjQ1NCA0OS4yMzkgTCAtMTQuNzU0IDQ5LjIzOSBMIC0xNC43NTQgNTMuNzQ5IEwgLTguMjg0IDUzLjc0OSBDIC04LjU3NCA1NS4yMjkgLTkuNDI0IDU2LjQ3OSAtMTAuNjg0IDU3LjMyOSBMIC0xMC42ODQgNjAuMzI5IEwgLTYuODI0IDYwLjMyOSBDIC00LjU2NCA1OC4yMzkgLTMuMjY0IDU1LjE1OSAtMy4yNjQgNTEuNTA5IFoiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDI3LjAwOSAtMzkuMjM5KSIvPjxwYXRoIGZpbGw9IiMzNEE4NTMiIGQ9Ik0gLTE0Ljc1NCA2My4yMzkgQyAtMTEuNTE0IDYzLjIzOSAtOC44MDQgNjIuMTU5IC02LjgyNCA2MC4zMjkgTCAtMTAuNjg0IDU3LjMyOSBDIC0xMS43NjQgNTguMDQ5IC0xMy4xMzQgNTguNDg5IC0xNC43NTQgNTguNDg5IEMgLTE3Ljg4NCA1OC40ODkgLTIwLjUzNCA1Ni4zNzkgLTIxLjQ4NCA1My41MjkgTCAtMjUuNDY0IDUzLjUyOSBMIC0yNS40NjQgNTYuNjE5IEMgLTIzLjQ5NCA2MC41MzkgLTE5LjQ0NCA2My4yMzkgLTE0Ljc1NCA2My4yMzkgWiIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMjcuMDA5IC0zOS4yMzkpIi8+PHBhdGggZmlsbD0iI0ZCQkMwNSIgZD0iTSAtMjEuNDg0IDUzLjUyOSBDIC0yMS43MzQgNTIuODA5IC0yMS44NjQgNTIuMDM5IC0yMS44NjQgNTEuMjM5IEMgLTIxLjg2NCA1MC40MzkgLTIxLjcyNCA0OS42NjkgLTIxLjQ4NCA0OC45NDkgTCAtMjEuNDg0IDQ1Ljg1OSBMIC0yNS40NjQgNDUuODU5IEMgLTI2LjI4NCA0Ny40NzkgLTI2Ljc1NCA0OS4yOTkgLTI2Ljc1NCA1MS4yMzkgQyAtMjYuNzU0IDUzLjE3OSAtMjYuMjg0IDU0Ljk5OSAtMjUuNDY0IDU2LjYxOSBMIC0yMS40ODQgNTMuNTI5IFoiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDI3LjAwOSAtMzkuMjM5KSIvPjxwYXRoIGZpbGw9IiNFQTQzMzUiIGQ9Ik0gLTE0Ljc1NCA0My45ODkgQyAtMTIuOTg0IDQzLjk4OSAtMTEuNDA0IDQ0LjU5OSAtMTAuMTU0IDQ1Ljc4OSBMIC02LjczNCA0Mi4zNjkgQyAtOC44MDQgNDAuNDI5IC0xMS41MTQgMzkuMjM5IC0xNC43NTQgMzkuMjM5IEMgLTE5LjQ0NCAzOS4yMzkgLTIzLjQ5NCA0MS45MzkgLTI1LjQ2NCA0NS44NTkgTCAtMjEuNDg0IDQ4Ljk0OSBDIC0yMC41MzQgNDYuMDk5IC0xNy44ODQgNDMuOTg5IC0xNC43NTQgNDMuOTg5IFoiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDI3LjAwOSAtMzkuMjM5KSIvPjwvc3ZnPg==');
   --icon-GristLogo: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDEiIGhlaWdodD0iMzgiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIj48cGF0aCBkPSJNMTAuODYyNDc0NSwwLjA5ODU5MTczNDcgTDUuMDE1NTg3NDMsMC4wOTg1OTE3MzQ3IEMyLjMxNzYzNDkxLDAuMDk4NTkxNzM0NyAwLjEyNzMwMTg2LDIuMjg4OTIwMDkgMC4xMjczMDE4Niw0Ljk4Njg0MTgzIEwwLjEyNzMwMTg2LDkuODU3ODg5NzIgTDYuMDIwMjM5OTUsOS44NTc4ODk3MiBDOC42OTI3ODUwMyw5Ljg1Nzg4OTcyIDEwLjg2MjQ3NDUsNy42ODgxMDEzMSAxMC44NjI0NzQ1LDUuMDE1NTk3NzUgTDEwLjg2MjQ3NDUsMC4wOTg1OTE3MzQ3IFoiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDI4LjEzNCAxLjE2MikiIGZpbGw9IiMyQ0IwQUYiLz48cGF0aCBkPSJNMTAuODYyNDc0NSwwLjA5ODU5MTczNDcgTDUuMDE1NTg3NDMsMC4wOTg1OTE3MzQ3IEMyLjMxNzYzNDkxLDAuMDk4NTkxNzM0NyAwLjEyNzMwMTg2LDIuMjg4OTIwMDkgMC4xMjczMDE4Niw0Ljk4Njg0MTgzIEwwLjEyNzMwMTg2LDkuODU3ODg5NzIgTDYuMDIwMjM5OTUsOS44NTc4ODk3MiBDOC42OTI3ODUwMyw5Ljg1Nzg4OTcyIDEwLjg2MjQ3NDUsNy42ODgxMDEzMSAxMC44NjI0NzQ1LDUuMDE1NTk3NzUgTDEwLjg2MjQ3NDUsMC4wOTg1OTE3MzQ3IFoiIHRyYW5zZm9ybT0ibWF0cml4KC0xIDAgMCAxIDI1LjU3IDEuMTYyKSIgZmlsbD0iIzJDQjBBRiIvPjxwYXRoIGQ9Ik0xMC44NjI0NzQ1LDAuMDk4NTkxNzM0NyBMNS4wMTU1ODc0MywwLjA5ODU5MTczNDcgQzIuMzE3NjM0OTEsMC4wOTg1OTE3MzQ3IDAuMTI3MzAxODYsMi4yODg5MjAwOSAwLjEyNzMwMTg2LDQuOTg2ODQxODMgTDAuMTI3MzAxODYsOS44NTc4ODk3MiBMNi4wMjAyMzk5NSw5Ljg1Nzg4OTcyIEM4LjY5Mjc4NTAzLDkuODU3ODg5NzIgMTAuODYyNDc0NSw3LjY4ODEwMTMxIDEwLjg2MjQ3NDUsNS4wMTU1OTc3NSBMMTAuODYyNDc0NSwwLjA5ODU5MTczNDcgWiIgdHJhbnNmb3JtPSJtYXRyaXgoLTEgMCAwIDEgMTIuMDE1IDEzLjc0KSIgZmlsbD0iI0Y5QUU0MSIvPjxwYXRoIGQ9Ik0xMC44NjI0NzQ1LDAuMDk4NTkxNzM0NyBMNS4wMTU1ODc0MywwLjA5ODU5MTczNDcgQzIuMzE3NjM0OTEsMC4wOTg1OTE3MzQ3IDAuMTI3MzAxODYsMi4yODg5MjAwOSAwLjEyNzMwMTg2LDQuOTg2ODQxODMgTDAuMTI3MzAxODYsOS44NTc4ODk3MiBMNi4wMjAyMzk5NSw5Ljg1Nzg4OTcyIEM4LjY5Mjc4NTAzLDkuODU3ODg5NzIgMTAuODYyNDc0NSw3LjY4ODEwMTMxIDEwLjg2MjQ3NDUsNS4wMTU1OTc3NSBMMTAuODYyNDc0NSwwLjA5ODU5MTczNDcgWiIgdHJhbnNmb3JtPSJtYXRyaXgoLTEgMCAwIDEgMTIuMDE1IDI2LjMxOSkiIGZpbGw9IiNGOUFFNDEiLz48cGF0aCBkPSJNMTAuODYyNDc0NSwwLjA5ODU5MTczNDcgTDUuMDE1NTg3NDMsMC4wOTg1OTE3MzQ3IEMyLjMxNzYzNDkxLDAuMDk4NTkxNzM0NyAwLjEyNzMwMTg2LDIuMjg4OTIwMDkgMC4xMjczMDE4Niw0Ljk4Njg0MTgzIEwwLjEyNzMwMTg2LDkuODU3ODg5NzIgTDYuMDIwMjM5OTUsOS44NTc4ODk3MiBDOC42OTI3ODUwMyw5Ljg1Nzg4OTcyIDEwLjg2MjQ3NDUsNy42ODgxMDEzMSAxMC44NjI0NzQ1LDUuMDE1NTk3NzUgTDEwLjg2MjQ3NDUsMC4wOTg1OTE3MzQ3IFoiIHRyYW5zZm9ybT0ibWF0cml4KC0xIDAgMCAxIDI1LjU3IDEzLjc0KSIgZmlsbD0iI0QyRDJEMiIvPjxwYXRoIGQ9Ik0xMC44NjI0NzQ1LDAuMDk4NTkxNzM0NyBMNS4wMTU1ODc0MywwLjA5ODU5MTczNDcgQzIuMzE3NjM0OTEsMC4wOTg1OTE3MzQ3IDAuMTI3MzAxODYsMi4yODg5MjAwOSAwLjEyNzMwMTg2LDQuOTg2ODQxODMgTDAuMTI3MzAxODYsOS44NTc4ODk3MiBMNi4wMjAyMzk5NSw5Ljg1Nzg4OTcyIEM4LjY5Mjc4NTAzLDkuODU3ODg5NzIgMTAuODYyNDc0NSw3LjY4ODEwMTMxIDEwLjg2MjQ3NDUsNS4wMTU1OTc3NSBMMTAuODYyNDc0NSwwLjA5ODU5MTczNDcgWiIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMjguMTM0IDEzLjc0KSIgZmlsbD0iI0QyRDJEMiIvPjxwYXRoIGQ9Ik0xMC44NjI0NzQ1LDAuMDk4NTkxNzM0NyBMNS4wMTU1ODc0MywwLjA5ODU5MTczNDcgQzIuMzE3NjM0OTEsMC4wOTg1OTE3MzQ3IDAuMTI3MzAxODYsMi4yODg5MjAwOSAwLjEyNzMwMTg2LDQuOTg2ODQxODMgTDAuMTI3MzAxODYsOS44NTc4ODk3MiBMNi4wMjAyMzk5NSw5Ljg1Nzg4OTcyIEM4LjY5Mjc4NTAzLDkuODU3ODg5NzIgMTAuODYyNDc0NSw3LjY4ODEwMTMxIDEwLjg2MjQ3NDUsNS4wMTU1OTc3NSBMMTAuODYyNDc0NSwwLjA5ODU5MTczNDcgWiIgdHJhbnNmb3JtPSJtYXRyaXgoLTEgMCAwIDEgMjUuNTcgMjYuMzE5KSIgZmlsbD0iI0QyRDJEMiIvPjxwYXRoIGQ9Ik0xMC44NjI0NzQ1LDAuMDk4NTkxNzM0NyBMNS4wMTU1ODc0MywwLjA5ODU5MTczNDcgQzIuMzE3NjM0OTEsMC4wOTg1OTE3MzQ3IDAuMTI3MzAxODYsMi4yODg5MjAwOSAwLjEyNzMwMTg2LDQuOTg2ODQxODMgTDAuMTI3MzAxODYsOS44NTc4ODk3MiBMNi4wMjAyMzk5NSw5Ljg1Nzg4OTcyIEM4LjY5Mjc4NTAzLDkuODU3ODg5NzIgMTAuODYyNDc0NSw3LjY4ODEwMTMxIDEwLjg2MjQ3NDUsNS4wMTU1OTc3NSBMMTAuODYyNDc0NSwwLjA5ODU5MTczNDcgWiIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMjguMTM0IDI2LjMxOSkiIGZpbGw9IiNEMkQyRDIiLz48L2c+PC9zdmc+');
   --icon-ThumbPreview: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDYiIGhlaWdodD0iNDYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiPjxkZWZzPjxwYXRoIGQ9Ik00LjA1NzYxNzE5LDYuMzY4MTY0MDYgTDQuMDU3NjE3MTksNS4xNjIxMDkzOCBMNy4xNzI4NTE1Niw1LjE2MjEwOTM4IEw3LjE3Mjg1MTU2LDguMDEzNjcxODggQzYuODcwMTE1NjcsOC4zMDY2NDIwOSA2LjQzMTQ4MDc0LDguNTY0NjE0NzcgNS44NTY5MzM1OSw4Ljc4NzU5NzY2IEM1LjI4MjM4NjQ1LDkuMDEwNTgwNTQgNC43MDA1MjM3OCw5LjEyMjA3MDMxIDQuMTExMzI4MTIsOS4xMjIwNzAzMSBDMy4zNjI2MjY0Niw5LjEyMjA3MDMxIDIuNzA5OTYzNzIsOC45NjUwMDgwOCAyLjE1MzMyMDMxLDguNjUwODc4OTEgQzEuNTk2Njc2OSw4LjMzNjc0OTczIDEuMTc4Mzg2ODIsNy44ODc1MzU0NyAwLjg5ODQzNzUsNy4zMDMyMjI2NiBDMC42MTg0ODgxODQsNi43MTg5MDk4NCAwLjQ3ODUxNTYyNSw2LjA4MzMzNjc3IDAuNDc4NTE1NjI1LDUuMzk2NDg0MzggQzAuNDc4NTE1NjI1LDQuNjUxMDM3OTQgMC42MzQ3NjQwNjIsMy45ODg2MDk2NyAwLjk0NzI2NTYyNSwzLjQwOTE3OTY5IEMxLjI1OTc2NzE5LDIuODI5NzQ5NzEgMS43MTcxMTkzOCwyLjM4NTQxODIxIDIuMzE5MzM1OTQsMi4wNzYxNzE4OCBDMi43NzgzMjI2MSwxLjgzODU0MDQ4IDMuMzQ5NjA1OTYsMS43MTk3MjY1NiA0LjAzMzIwMzEyLDEuNzE5NzI2NTYgQzQuOTIxODc5NDQsMS43MTk3MjY1NiA1LjYxNjA0NTY4LDEuOTA2MDg1MzggNi4xMTU3MjI2NiwyLjI3ODgwODU5IEM2LjYxNTM5OTYzLDIuNjUxNTMxODEgNi45MzY4NDgyNCwzLjE2NjY2MzM4IDcuMDgwMDc4MTIsMy44MjQyMTg3NSBMNS42NDQ1MzEyNSw0LjA5Mjc3MzQ0IEM1LjU0MzYxOTI5LDMuNzQxMjA5MTggNS4zNTQwMDUzLDMuNDYzNzA1NDQgNS4wNzU2ODM1OSwzLjI2MDI1MzkxIEM0Ljc5NzM2MTg5LDMuMDU2ODAyMzcgNC40NDk4NzE4NywyLjk1NTA3ODEyIDQuMDMzMjAzMTIsMi45NTUwNzgxMiBDMy40MDE2ODk1NSwyLjk1NTA3ODEyIDIuODk5NTc4NjksMy4xNTUyNzE0NCAyLjUyNjg1NTQ3LDMuNTU1NjY0MDYgQzIuMTU0MTMyMjUsMy45NTYwNTY2OSAxLjk2Nzc3MzQ0LDQuNTUwMTI2MjcgMS45Njc3NzM0NCw1LjMzNzg5MDYyIEMxLjk2Nzc3MzQ0LDYuMTg3NTA0MjUgMi4xNTY1NzM2Myw2LjgyNDcwNDkxIDIuNTM0MTc5NjksNy4yNDk1MTE3MiBDMi45MTE3ODU3NCw3LjY3NDMxODUzIDMuNDA2NTcyNDYsNy44ODY3MTg3NSA0LjAxODU1NDY5LDcuODg2NzE4NzUgQzQuMzIxMjkwNTgsNy44ODY3MTg3NSA0LjYyNDgzNTcyLDcuODI3MzExNzkgNC45MjkxOTkyMiw3LjcwODQ5NjA5IEM1LjIzMzU2MjcyLDcuNTg5NjgwNCA1LjQ5NDc5MDU4LDcuNDQ1NjM4ODcgNS43MTI4OTA2Miw3LjI3NjM2NzE5IEw1LjcxMjg5MDYyLDYuMzY4MTY0MDYgTDQuMDU3NjE3MTksNi4zNjgxNjQwNiBaIE05LjIxMDc0MjE4LDkgTDkuMjEwNzQyMTgsMS44NDE3OTY4OCBMMTIuMjUyNzM0NCwxLjg0MTc5Njg4IEMxMy4wMTc3MTIxLDEuODQxNzk2ODggMTMuNTczNTMzNCwxLjkwNjA4NjYgMTMuOTIwMjE0OCwyLjAzNDY2Nzk3IEMxNC4yNjY4OTYzLDIuMTYzMjQ5MzQgMTQuNTQ0NCwyLjM5MTkyNTQ0IDE0Ljc1MjczNDQsMi43MjA3MDMxMiBDMTQuOTYxMDY4NywzLjA0OTQ4MDgxIDE1LjA2NTIzNDQsMy40MjU0NTM2MSAxNS4wNjUyMzQ0LDMuODQ4NjMyODEgQzE1LjA2NTIzNDQsNC4zODU3NDQ4NyAxNC45MDczNTgzLDQuODI5MjYyNTcgMTQuNTkxNjAxNiw1LjE3OTE5OTIyIEMxNC4yNzU4NDQ4LDUuNTI5MTM1ODYgMTMuODAzODQ0Myw1Ljc0OTY3NDAyIDEzLjE3NTU4NTksNS44NDA4MjAzMSBDMTMuNDg4MDg3NSw2LjAyMzExMjg5IDEzLjc0NjA2MDIsNi4yMjMzMDYyIDEzLjk0OTUxMTcsNi40NDE0MDYyNSBDMTQuMTUyOTYzMiw2LjY1OTUwNjMgMTQuNDI3MjExOCw3LjA0Njg3MjIyIDE0Ljc3MjI2NTYsNy42MDM1MTU2MiBMMTUuNjQ2Mjg5MSw5IEwxMy45MTc3NzM0LDkgTDEyLjg3Mjg1MTYsNy40NDIzODI4MSBDMTIuNTAxNzU1OSw2Ljg4NTczOTQgMTIuMjQ3ODUyMiw2LjUzNDk5NDIxIDEyLjExMTEzMjgsNi4zOTAxMzY3MiBDMTEuOTc0NDEzNCw2LjI0NTI3OTIyIDExLjgyOTU1OCw2LjE0NTk5NjM2IDExLjY3NjU2MjUsNi4wOTIyODUxNiBDMTEuNTIzNTY2OSw2LjAzODU3Mzk1IDExLjI4MTA1NjMsNi4wMTE3MTg3NSAxMC45NDkwMjM0LDYuMDExNzE4NzUgTDEwLjY1NjA1NDcsNi4wMTE3MTg3NSBMMTAuNjU2MDU0Nyw5IEw5LjIxMDc0MjE4LDkgWiBNMTAuNjU2MDU0Nyw0Ljg2OTE0MDYyIEwxMS43MjUzOTA2LDQuODY5MTQwNjIgQzEyLjQxODc1MzUsNC44NjkxNDA2MiAxMi44NTE2OTE4LDQuODM5ODQ0MDQgMTMuMDI0MjE4Nyw0Ljc4MTI1IEMxMy4xOTY3NDU2LDQuNzIyNjU1OTYgMTMuMzMxODM1NCw0LjYyMTc0NTUxIDEzLjQyOTQ5MjIsNC40Nzg1MTU2MiBDMTMuNTI3MTQ4OSw0LjMzNTI4NTc0IDEzLjU3NTk3NjYsNC4xNTYyNTEwNyAxMy41NzU5NzY2LDMuOTQxNDA2MjUgQzEzLjU3NTk3NjYsMy43MDA1MTk2MyAxMy41MTE2ODY4LDMuNTA2MDIyODggMTMuMzgzMTA1NSwzLjM1NzkxMDE2IEMxMy4yNTQ1MjQxLDMuMjA5Nzk3NDQgMTMuMDczMDQ4LDMuMTE2MjExMTMgMTIuODM4NjcxOSwzLjA3NzE0ODQ0IEMxMi43MjE0ODM4LDMuMDYwODcyMzEgMTIuMzY5OTI0OCwzLjA1MjczNDM4IDExLjc4Mzk4NDQsMy4wNTI3MzQzOCBMMTAuNjU2MDU0NywzLjA1MjczNDM4IEwxMC42NTYwNTQ3LDQuODY5MTQwNjIgWiBNMTcuMDgzNTkzNyw5IEwxNy4wODM1OTM3LDEuODQxNzk2ODggTDE4LjUyODkwNjIsMS44NDE3OTY4OCBMMTguNTI4OTA2Miw5IEwxNy4wODM1OTM3LDkgWiBNMjAuMjM5NjQ4NCw2LjY3MDg5ODQ0IEwyMS42NDU4OTg0LDYuNTM0MTc5NjkgQzIxLjczMDUzNDIsNy4wMDYxODcyNiAyMS45MDIyNDQ4LDcuMzUyODYzNDggMjIuMTYxMDM1MSw3LjU3NDIxODc1IEMyMi40MTk4MjU1LDcuNzk1NTc0MDIgMjIuNzY4OTQzMSw3LjkwNjI1IDIzLjIwODM5ODQsNy45MDYyNSBDMjMuNjczODk1NSw3LjkwNjI1IDI0LjAyNDY0MDcsNy44MDc3ODA5MyAyNC4yNjA2NDQ1LDcuNjEwODM5ODQgQzI0LjQ5NjY0ODMsNy40MTM4OTg3NSAyNC42MTQ2NDg0LDcuMTgzNTk1MDcgMjQuNjE0NjQ4NCw2LjkxOTkyMTg4IEMyNC42MTQ2NDg0LDYuNzUwNjUwMiAyNC41NjUwMDcsNi42MDY2MDg2NyAyNC40NjU3MjI2LDYuNDg3NzkyOTcgQzI0LjM2NjQzODMsNi4zNjg5NzcyNyAyNC4xOTMxMDAyLDYuMjY1NjI1NDQgMjMuOTQ1NzAzMSw2LjE3NzczNDM4IEMyMy43NzY0MzE0LDYuMTE5MTQwMzMgMjMuMzkwNjkzMSw2LjAxNDk3NDcxIDIyLjc4ODQ3NjUsNS44NjUyMzQzOCBDMjIuMDEzNzMzMSw1LjY3MzE3NjEyIDIxLjQ3MDExODcsNS40MzcxNzU4OCAyMS4xNTc2MTcyLDUuMTU3MjI2NTYgQzIwLjcxODE2MTgsNC43NjMzNDQzOCAyMC40OTg0Mzc1LDQuMjgzMjA1OTYgMjAuNDk4NDM3NSwzLjcxNjc5Njg4IEMyMC40OTg0Mzc1LDMuMzUyMjExNzIgMjAuNjAxNzg5MywzLjAxMTIzMjA2IDIwLjgwODQ5NjEsMi42OTM4NDc2NiBDMjEuMDE1MjAyOCwyLjM3NjQ2MzI2IDIxLjMxMzA1MTQsMi4xMzQ3NjY0NiAyMS43MDIwNTA3LDEuOTY4NzUgQzIyLjA5MTA1MDEsMS44MDI3MzM1NCAyMi41NjA2MDkyLDEuNzE5NzI2NTYgMjMuMTEwNzQyMiwxLjcxOTcyNjU2IEMyNC4wMDkxODQxLDEuNzE5NzI2NTYgMjQuNjg1NDQ2OSwxLjkxNjY2NDcgMjUuMTM5NTUwNywyLjMxMDU0Njg4IEMyNS41OTM2NTQ2LDIuNzA0NDI5MDUgMjUuODMyMDk2MiwzLjIzMDEzOTk0IDI1Ljg1NDg4MjgsMy44ODc2OTUzMSBMMjQuNDA5NTcwMywzLjk1MTE3MTg4IEMyNC4zNDc3MjEsMy41ODMzMzE0OSAyNC4yMTUwNzI2LDMuMzE4ODQ4NDYgMjQuMDExNjIxMSwzLjE1NzcxNDg0IEMyMy44MDgxNjk1LDIuOTk2NTgxMjMgMjMuNTAyOTk2OCwyLjkxNjAxNTYyIDIzLjA5NjA5MzcsMi45MTYwMTU2MiBDMjIuNjc2MTY5NywyLjkxNjAxNTYyIDIyLjM0NzM5NywzLjAwMjI3Nzc4IDIyLjEwOTc2NTYsMy4xNzQ4MDQ2OSBDMjEuOTU2NzcsMy4yODU0ODIzMiAyMS44ODAyNzM0LDMuNDMzNTkyODIgMjEuODgwMjczNCwzLjYxOTE0MDYyIEMyMS44ODAyNzM0LDMuNzg4NDEyMyAyMS45NTE4ODczLDMuOTMzMjY3NjMgMjIuMDk1MTE3Miw0LjA1MzcxMDk0IEMyMi4yNzc0MDk3LDQuMjA2NzA2NDkgMjIuNzIwMTEzNiw0LjM2NjIxMDExIDIzLjQyMzI0MjIsNC41MzIyMjY1NiBDMjQuMTI2MzcwNyw0LjY5ODI0MzAyIDI0LjY0NjM4NSw0Ljg2OTk1MzU0IDI0Ljk4MzMwMDcsNS4wNDczNjMyOCBDMjUuMzIwMjE2NSw1LjIyNDc3MzAyIDI1LjU4Mzg4NTcsNS40NjcyODM2MiAyNS43NzQzMTY0LDUuNzc0OTAyMzQgQzI1Ljk2NDc0Nyw2LjA4MjUyMTA3IDI2LjA1OTk2MDksNi40NjI1NjI4NCAyNi4wNTk5NjA5LDYuOTE1MDM5MDYgQzI2LjA1OTk2MDksNy4zMjUxOTczNiAyNS45NDYwMjk3LDcuNzA5MzA4MTEgMjUuNzE4MTY0LDguMDY3MzgyODEgQzI1LjQ5MDI5ODMsOC40MjU0NTc1MiAyNS4xNjgwMzU5LDguNjkxNTY4MTQgMjQuNzUxMzY3Miw4Ljg2NTcyMjY2IEMyNC4zMzQ2OTg0LDkuMDM5ODc3MTcgMjMuODE1NDk3OSw5LjEyNjk1MzEyIDIzLjE5Mzc1LDkuMTI2OTUzMTIgQzIyLjI4ODc5NzUsOS4xMjY5NTMxMiAyMS41OTM4MTc1LDguOTE3ODA4MDggMjEuMTA4Nzg5LDguNDk5NTExNzIgQzIwLjYyMzc2MDYsOC4wODEyMTUzNiAyMC4zMzQwNDk5LDcuNDcxNjgzNjkgMjAuMjM5NjQ4NCw2LjY3MDg5ODQ0IFogTTI5LjU4NzEwOTMsOSBMMjkuNTg3MTA5MywzLjA1MjczNDM4IEwyNy40NjMwODU5LDMuMDUyNzM0MzggTDI3LjQ2MzA4NTksMS44NDE3OTY4OCBMMzMuMTUxNTYyNSwxLjg0MTc5Njg4IEwzMy4xNTE1NjI1LDMuMDUyNzM0MzggTDMxLjAzMjQyMTgsMy4wNTI3MzQzOCBMMzEuMDMyNDIxOCw5IEwyOS41ODcxMDkzLDkgWiIgaWQ9ImEiLz48L2RlZnM+PGcgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIiBvcGFjaXR5PSIuOSI+PHBhdGggZD0iTTAgMEg0OFY0OEgweiIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTEgLTEpIi8+PHBhdGggZmlsbD0iIzBFNTEyQiIgZD0iTTQzIDI2TDMgMjYgMCAyMiA0IDE4IDQyIDE4IDQ2IDIyeiIvPjxwYXRoIGQ9Ik00MCw0NiBMNiw0NiBDNC44OTUsNDYgNCw0NS4xMDUgNCw0NCBMNCwyIEM0LDAuODk1IDQuODk1LDAgNiwwIEwzMCwwIEw0MiwxMiBMNDIsNDQgQzQyLDQ1LjEwNSA0MS4xMDUsNDYgNDAsNDYgWiIgZmlsbD0iI0U2RTZFNiIvPjxwYXRoIGQ9Ik0zMCwwIEwzMCwxMCBDMzAsMTEuMTA1IDMwLjg5NSwxMiAzMiwxMiBMNDIsMTIgTDMwLDAgWiIgZmlsbD0iI0IzQjNCMyIvPjxwYXRoIGQ9Ik00NCw0MCBMMiw0MCBDMC44OTUsNDAgMCwzOS4xMDUgMCwzOCBMMCwyMiBMNDYsMjIgTDQ2LDM4IEM0NiwzOS4xMDUgNDUuMTA1LDQwIDQ0LDQwIFoiIGZpbGw9IiMxNkIzNzgiLz48ZyB0cmFuc2Zvcm09InRyYW5zbGF0ZSg2IDI1KSI+PHVzZSBmaWxsPSIjMDAwIiB4bGluazpocmVmPSIjYSIvPjx1c2UgZmlsbD0iI0ZGRiIgeGxpbms6aHJlZj0iI2EiLz48L2c+PC9nPjwvc3ZnPg==');
@@ -134,13 +134,17 @@
   --icon-Separator: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgY2xpcC1wYXRoPSJ1cmwoI2NsaXAwXzEwNjFfNzU4OCkiIGZpbGw9IiMwMDAiPjxwYXRoIGQ9Ik0xLjMzMyA3SDE0LjY2NlY4LjMzM0gxLjMzM3pNMCA1SDEuMzMzVjEwLjMzM0gwek0xNC42NjcgNUgxNlYxMC4zMzNIMTQuNjY3eiIvPjwvZz48ZGVmcz48Y2xpcFBhdGggaWQ9ImNsaXAwXzEwNjFfNzU4OCI+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTAgMEgxNlYxNkgweiIvPjwvY2xpcFBhdGg+PC9kZWZzPjwvc3ZnPg==');
   --icon-Settings: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTMuOTgwNDcxOSwzLjI3MzM2NTEyIEM0LjgxNDE0NDY1LDIuNTc4NDA2ODQgNS44NTY2MjI5MSwyLjEyNTQ1Njc0IDcsMi4wMjI0MjE1MSBMNywwLjUgQzcsMC4yMjM4NTc2MjUgNy4yMjM4NTc2MywwIDcuNSwwIEM3Ljc3NjE0MjM3LDAgOCwwLjIyMzg1NzYyNSA4LDAuNSBMOCwyLjAyMjQyMTUxIEM5LjE0MzM3NzA5LDIuMTI1NDU2NzQgMTAuMTg1ODU1NCwyLjU3ODQwNjg0IDExLjAxOTUyODEsMy4yNzMzNjUxMiBMMTIuMDk2NDQ2NiwyLjE5NjQ0NjYxIEMxMi4yOTE3MDg4LDIuMDAxMTg0NDYgMTIuNjA4MjkxMiwyLjAwMTE4NDQ2IDEyLjgwMzU1MzQsMi4xOTY0NDY2MSBDMTIuOTk4ODE1NSwyLjM5MTcwODc2IDEyLjk5ODgxNTUsMi43MDgyOTEyNCAxMi44MDM1NTM0LDIuOTAzNTUzMzkgTDExLjcyNjYzNDksMy45ODA0NzE5IEMxMi40MjE1OTMyLDQuODE0MTQ0NjUgMTIuODc0NTQzMyw1Ljg1NjYyMjkxIDEyLjk3NzU3ODUsNyBMMTQuNSw3IEMxNC43NzYxNDI0LDcgMTUsNy4yMjM4NTc2MyAxNSw3LjUgQzE1LDcuNzc2MTQyMzcgMTQuNzc2MTQyNCw4IDE0LjUsOCBMMTIuOTc3NTc4NSw4IEMxMi44NzQ1NDMzLDkuMTQzMzc3MDkgMTIuNDIxNTkzMiwxMC4xODU4NTU0IDExLjcyNjYzNDksMTEuMDE5NTI4MSBMMTIuODAzNTUzNCwxMi4wOTY0NDY2IEMxMi45OTg4MTU1LDEyLjI5MTcwODggMTIuOTk4ODE1NSwxMi42MDgyOTEyIDEyLjgwMzU1MzQsMTIuODAzNTUzNCBDMTIuNjA4MjkxMiwxMi45OTg4MTU1IDEyLjI5MTcwODgsMTIuOTk4ODE1NSAxMi4wOTY0NDY2LDEyLjgwMzU1MzQgTDExLjAxOTUyODEsMTEuNzI2NjM0OSBDMTAuMTg1ODU1NCwxMi40MjE1OTMyIDkuMTQzMzc3MDksMTIuODc0NTQzMyA4LDEyLjk3NzU3ODUgTDgsMTQuNSBDOCwxNC43NzYxNDI0IDcuNzc2MTQyMzcsMTUgNy41LDE1IEM3LjIyMzg1NzYzLDE1IDcsMTQuNzc2MTQyNCA3LDE0LjUgTDcsMTIuOTc3NTc4NSBDNS44NTY2MjI5MSwxMi44NzQ1NDMzIDQuODE0MTQ0NjUsMTIuNDIxNTkzMiAzLjk4MDQ3MTksMTEuNzI2NjM0OSBMMi45MDM1NTMzOSwxMi44MDM1NTM0IEMyLjcwODI5MTI0LDEyLjk5ODgxNTUgMi4zOTE3MDg3NiwxMi45OTg4MTU1IDIuMTk2NDQ2NjEsMTIuODAzNTUzNCBDMi4wMDExODQ0NiwxMi42MDgyOTEyIDIuMDAxMTg0NDYsMTIuMjkxNzA4OCAyLjE5NjQ0NjYxLDEyLjA5NjQ0NjYgTDMuMjczMzY1MTIsMTEuMDE5NTI4MSBDMi41Nzg0MDY4NCwxMC4xODU4NTU0IDIuMTI1NDU2NzQsOS4xNDMzNzcwOSAyLjAyMjQyMTUxLDggTDAuNSw4IEMwLjIyMzg1NzYyNSw4IDAsNy43NzYxNDIzNyAwLDcuNSBDMCw3LjIyMzg1NzYzIDAuMjIzODU3NjI1LDcgMC41LDcgTDIuMDIyNDIxNTEsNyBDMi4xMjU0NTY3NCw1Ljg1NjYyMjkxIDIuNTc4NDA2ODQsNC44MTQxNDQ2NSAzLjI3MzM2NTEyLDMuOTgwNDcxOSBMMi4xOTY0NDY2MSwyLjkwMzU1MzM5IEMyLjAwMTE4NDQ2LDIuNzA4MjkxMjQgMi4wMDExODQ0NiwyLjM5MTcwODc2IDIuMTk2NDQ2NjEsMi4xOTY0NDY2MSBDMi4zOTE3MDg3NiwyLjAwMTE4NDQ2IDIuNzA4MjkxMjQsMi4wMDExODQ0NiAyLjkwMzU1MzM5LDIuMTk2NDQ2NjEgTDMuOTgwNDcxOSwzLjI3MzM2NTEyIFogTTcuNSwxMCBDNi4xMTkyODgxMywxMCA1LDguODgwNzExODcgNSw3LjUgQzUsNi4xMTkyODgxMyA2LjExOTI4ODEzLDUgNy41LDUgQzguODgwNzExODcsNSAxMCw2LjExOTI4ODEzIDEwLDcuNSBDMTAsOC44ODA3MTE4NyA4Ljg4MDcxMTg3LDEwIDcuNSwxMCBaIE03LjUsOSBDOC4zMjg0MjcxMiw5IDksOC4zMjg0MjcxMiA5LDcuNSBDOSw2LjY3MTU3Mjg4IDguMzI4NDI3MTIsNiA3LjUsNiBDNi42NzE1NzI4OCw2IDYsNi42NzE1NzI4OCA2LDcuNSBDNiw4LjMyODQyNzEyIDYuNjcxNTcyODgsOSA3LjUsOSBaIE03LjUsMTIgQzkuOTg1MjgxMzcsMTIgMTIsOS45ODUyODEzNyAxMiw3LjUgQzEyLDUuMDE0NzE4NjMgOS45ODUyODEzNywzIDcuNSwzIEM1LjAxNDcxODYzLDMgMyw1LjAxNDcxODYzIDMsNy41IEMzLDkuOTg1MjgxMzcgNS4wMTQ3MTg2MywxMiA3LjUsMTIgWiIgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIi8+PC9zdmc+');
   --icon-Share: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTYuNzc0MTEwMDUsOS4xNDQxMzAyOSBMOS43NTY4Nzg3NiwxMS4wMDc4NjUxIEMxMC4zMDY0MDY1LDEwLjM4OTU1MjMgMTEuMTA3NzEwOSwxMCAxMiwxMCBDMTMuNjU2ODU0MiwxMCAxNSwxMS4zNDMxNDU4IDE1LDEzIEMxNSwxNC42NTY4NTQyIDEzLjY1Njg1NDIsMTYgMTIsMTYgQzEwLjM0MzE0NTgsMTYgOSwxNC42NTY4NTQyIDksMTMgQzksMTIuNTk0NjU3MiA5LjA4MDM4OTUzLDEyLjIwODA5MDQgOS4yMjYwOTUwNywxMS44NTUzNzMgTDYuMjQzNDc4MjQsOS45OTE3MzMxNyBDNS42OTM5NDA1NSwxMC42MTAyNzkgNC44OTI0ODIzOSwxMSA0LDExIEMyLjM0MzE0NTc1LDExIDEsOS42NTY4NTQyNSAxLDggQzEsNi4zNDMxNDU3NSAyLjM0MzE0NTc1LDUgNCw1IEM0Ljg5MjQ4MjM5LDUgNS42OTM5NDA1NSw1LjM4OTcyMTAzIDYuMjQzNDc4MjQsNi4wMDgyNjY4MyBMOS4yMjYwOTUwNyw0LjE0NDYyNjk2IEM5LjA4MDM4OTUzLDMuNzkxOTA5NjMgOSwzLjQwNTM0MjggOSwzIEM5LDEuMzQzMTQ1NzUgMTAuMzQzMTQ1OCwwIDEyLDAgQzEzLjY1Njg1NDIsMCAxNSwxLjM0MzE0NTc1IDE1LDMgQzE1LDQuNjU2ODU0MjUgMTMuNjU2ODU0Miw2IDEyLDYgQzExLjEwNzcxMDksNiAxMC4zMDY0MDY1LDUuNjEwNDQ3NzMgOS43NTY4Nzg3Niw0Ljk5MjEzNDkzIEw2Ljc3NDExMDA1LDYuODU1ODY5NzEgQzYuOTE5Njg1OTIsNy4yMDg0NTMyNSA3LDcuNTk0ODQ3NDQgNyw4IEM3LDguNDA1MTUyNTYgNi45MTk2ODU5Miw4Ljc5MTU0Njc1IDYuNzc0MTEwMDUsOS4xNDQxMzAyOSBaIE01LjcwNDEyNDg1LDkuMDQ3NDE3MTYgQzUuODkxNzYwNjYsOC43NDI3ODczNCA2LDguMzg0MDM0IDYsOCBDNiw3LjYxNTk2NiA1Ljg5MTc2MDY2LDcuMjU3MjEyNjYgNS43MDQxMjQ4NSw2Ljk1MjU4Mjg0IEM1LjcwMTM1MzQ2LDYuOTQ4NDI1NzcgNS42OTg2MzQ0Miw2Ljk0NDIxNDMyIDUuNjk1OTY5MTgsNi45Mzk5NDg4IEM1LjY5MzQ2MjQ3LDYuOTM1OTM2OTkgNS42OTEwMTk1OSw2LjkzMTkwMzM4IDUuNjg4NjQwMjksNi45Mjc4NDkxNSBDNS4zMzM3NDk5MSw2LjM3MDA2MTY1IDQuNzEwMDkxMjIsNiA0LDYgQzIuODk1NDMwNSw2IDIsNi44OTU0MzA1IDIsOCBDMiw5LjEwNDU2OTUgMi44OTU0MzA1LDEwIDQsMTAgQzQuNzEwMDkxMjIsMTAgNS4zMzM3NDk5MSw5LjYyOTkzODM1IDUuNjg4NjQwMjksOS4wNzIxNTA4NSBDNS42OTEwMTk1OSw5LjA2ODA5NjYyIDUuNjkzNDYyNDcsOS4wNjQwNjMwMSA1LjY5NTk2OTE4LDkuMDYwMDUxMiBDNS42OTg2MzQ0Miw5LjA1NTc4NTY4IDUuNzAxMzUzNDYsOS4wNTE1NzQyMyA1LjcwNDEyNDg1LDkuMDQ3NDE3MTYgWiBNMTAuMzE5OTA5MiwxMS45MTQ1MjgzIEMxMC4zMTUyMjY4LDExLjkyMzA4OTcgMTAuMzEwMjY4MSwxMS45MzE1NjY5IDEwLjMwNTAzMDgsMTEuOTM5OTQ4OCBDMTAuMjk5NTk2LDExLjk0ODY0NjggMTAuMjkzOTM3NiwxMS45NTcxMTk5IDEwLjI4ODA2NzYsMTEuOTY1MzY1NSBDMTAuMTA1MjQsMTIuMjY3MjI4NiAxMCwxMi42MjEzMjQzIDEwLDEzIEMxMCwxNC4xMDQ1Njk1IDEwLjg5NTQzMDUsMTUgMTIsMTUgQzEzLjEwNDU2OTUsMTUgMTQsMTQuMTA0NTY5NSAxNCwxMyBDMTQsMTEuODk1NDMwNSAxMy4xMDQ1Njk1LDExIDEyLDExIEMxMS4yOTU1NzY3LDExIDEwLjY3NjIxMTcsMTEuMzY0MTc3NSAxMC4zMTk5MDkyLDExLjkxNDUyODMgWiBNMTAuMjg4MDY3Niw0LjAzNDYzNDU0IEMxMC4yOTM5Mzc2LDQuMDQyODgwMDkgMTAuMjk5NTk2LDQuMDUxMzUzMjUgMTAuMzA1MDMwOCw0LjA2MDA1MTIgQzEwLjMxMDI2ODEsNC4wNjg0MzMxNSAxMC4zMTUyMjY4LDQuMDc2OTEwMjUgMTAuMzE5OTA5Miw0LjA4NTQ3MTY4IEMxMC42NzYyMTE3LDQuNjM1ODIyNDUgMTEuMjk1NTc2Nyw1IDEyLDUgQzEzLjEwNDU2OTUsNSAxNCw0LjEwNDU2OTUgMTQsMyBDMTQsMS44OTU0MzA1IDEzLjEwNDU2OTUsMSAxMiwxIEMxMC44OTU0MzA1LDEgMTAsMS44OTU0MzA1IDEwLDMgQzEwLDMuMzc4Njc1NzQgMTAuMTA1MjQsMy43MzI3NzEzNyAxMC4yODgwNjc2LDQuMDM0NjM0NTQgWiIgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIi8+PC9zdmc+');
+  --icon-Skip: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjEiIGhlaWdodD0iMjEiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xNy4wNjI1IDMuODIxMDRIMTUuODEyNVYxNi4zMjFIMTcuMDYyNVYzLjgyMTA0Wk0xNC4yNjQzIDEwLjk2NDhDMTQuNTc4NSAxMC43NzY2IDE0Ljc3MDggMTAuNDM3MyAxNC43NzA4IDEwLjA3MUMxNC43NzA4IDkuNzA0OCAxNC41Nzg1IDkuMzY1NDYgMTQuMjY0MyA5LjE3NzMyTDUuODI2NzcgNC4xMjU1MUM1LjUwNDkzIDMuOTMyODEgNS4xMDQzNSAzLjkyODA0IDQuNzc4MDIgNC4xMTMwMUM0LjQ1MTY4IDQuMjk3OTggNC4yNSA0LjY0NDEyIDQuMjUgNS4wMTkyM1YxNS4xMjI5QzQuMjUgMTUuNDk4IDQuNDUxNjggMTUuODQ0MSA0Ljc3ODAyIDE2LjAyOTFDNS4xMDQzNSAxNi4yMTQgNS41MDQ5MyAxNi4yMDkzIDUuODI2NzcgMTYuMDE2NkwxNC4yNjQzIDEwLjk2NDhaTTUuMjkxNjcgNS4wMTkyM0wxMy43MjkyIDEwLjA3MUw1LjI5MTY3IDE1LjEyMjlMNS4yOTE2NyA1LjAxOTIzWiIgZmlsbD0iIzE2QjM3OCIvPjwvc3ZnPg==');
   --icon-Sort: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTMuOTk5MjUsMy43MDY1NTM3NyBMMy45OTkyNSwxMy40OTkyNSBDMy45OTkyNSwxMy43NzUyNSAzLjc3NTI1LDEzLjk5OTI1IDMuNDk5MjUsMTMuOTk5MjUgQzMuMjIzMjUsMTMuOTk5MjUgMi45OTkyNSwxMy43NzUyNSAyLjk5OTI1LDEzLjQ5OTI1IEwyLjk5OTI1LDMuNzA2NTUzNzcgTDEuODUzMjUsNC44NTMyNSBDMS42NTgyNSw1LjA0ODI1IDEuMzQxMjUsNS4wNDgyNSAxLjE0NjI1LDQuODUzMjUgQzAuOTUxMjUsNC42NTgyNSAwLjk1MTI1LDQuMzQxMjUgMS4xNDYyNSw0LjE0NjI1IEwzLjE0NjI1LDIuMTQ2MjUgQzMuMzQxMjUsMS45NTEyNSAzLjY1ODI1LDEuOTUxMjUgMy44NTMyNSwyLjE0NjI1IEw1Ljg1MzI1LDQuMTQ2MjUgQzYuMDQ4MjUsNC4zNDEyNSA2LjA0ODI1LDQuNjU4MjUgNS44NTMyNSw0Ljg1MzI1IEM1Ljc1NTI1LDQuOTUwMjUgNS42MjcyNSw0Ljk5OTI1IDUuNDk5MjUsNC45OTkyNSBDNS4zNzEyNSw0Ljk5OTI1IDUuMjQzMjUsNC45NTAyNSA1LjE0NTI1LDQuODUzMjUgTDMuOTk5MjUsMy43MDY1NTM3NyBaIE04LjQ5OTI1LDMuOTk5MjUgTDcuNDk5MjUsMy45OTkyNSBDNy4yMjMyNSwzLjk5OTI1IDYuOTk5MjUsMy43NzUyNSA2Ljk5OTI1LDMuNDk5MjUgQzYuOTk5MjUsMy4yMjMyNSA3LjIyMzI1LDIuOTk5MjUgNy40OTkyNSwyLjk5OTI1IEw4LjQ5OTI1LDIuOTk5MjUgQzguNzc1MjUsMi45OTkyNSA4Ljk5OTI1LDMuMjIzMjUgOC45OTkyNSwzLjQ5OTI1IEM4Ljk5OTI1LDMuNzc1MjUgOC43NzUyNSwzLjk5OTI1IDguNDk5MjUsMy45OTkyNSBaIE0xMC40OTkyNSw2Ljk5OTI1IEw3LjQ5OTI1LDYuOTk5MjUgQzcuMjIzMjUsNi45OTkyNSA2Ljk5OTI1LDYuNzc1MjUgNi45OTkyNSw2LjQ5OTI1IEM2Ljk5OTI1LDYuMjIzMjUgNy4yMjMyNSw1Ljk5OTI1IDcuNDk5MjUsNS45OTkyNSBMMTAuNDk5MjUsNS45OTkyNSBDMTAuNzc1MjUsNS45OTkyNSAxMC45OTkyNSw2LjIyMzI1IDEwLjk5OTI1LDYuNDk5MjUgQzEwLjk5OTI1LDYuNzc1MjUgMTAuNzc1MjUsNi45OTkyNSAxMC40OTkyNSw2Ljk5OTI1IFogTTEyLjQ5OTI1LDkuOTk5MjUgTDcuNDk5MjUsOS45OTkyNSBDNy4yMjMyNSw5Ljk5OTI1IDYuOTk5MjUsOS43NzUyNSA2Ljk5OTI1LDkuNDk5MjUgQzYuOTk5MjUsOS4yMjMyNSA3LjIyMzI1LDguOTk5MjUgNy40OTkyNSw4Ljk5OTI1IEwxMi40OTkyNSw4Ljk5OTI1IEMxMi43NzUyNSw4Ljk5OTI1IDEyLjk5OTI1LDkuMjIzMjUgMTIuOTk5MjUsOS40OTkyNSBDMTIuOTk5MjUsOS43NzUyNSAxMi43NzUyNSw5Ljk5OTI1IDEyLjQ5OTI1LDkuOTk5MjUgWiBNMTQuNDk5MjUsMTIuOTk5MjUgTDcuNDk5MjUsMTIuOTk5MjUgQzcuMjIzMjUsMTIuOTk5MjUgNi45OTkyNSwxMi43NzUyNSA2Ljk5OTI1LDEyLjQ5OTI1IEM2Ljk5OTI1LDEyLjIyMzI1IDcuMjIzMjUsMTEuOTk5MjUgNy40OTkyNSwxMS45OTkyNSBMMTQuNDk5MjUsMTEuOTk5MjUgQzE0Ljc3NTI1LDExLjk5OTI1IDE0Ljk5OTI1LDEyLjIyMzI1IDE0Ljk5OTI1LDEyLjQ5OTI1IEMxNC45OTkyNSwxMi43NzUyNSAxNC43NzUyNSwxMi45OTkyNSAxNC40OTkyNSwxMi45OTkyNSBaIiBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9Im5vbnplcm8iIHRyYW5zZm9ybT0ibWF0cml4KDEgMCAwIC0xIDAgMTYpIi8+PC9zdmc+');
   --icon-Sparks: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDYiIGhlaWdodD0iNDQiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTM0LjYyNDggMzAuMjE5QzMzLjU1NzUgMjkuNDYxMiAzMi4yODkzIDI5LjAzNjkgMzAuOTgwOCAyOUMyOS4wNDE4IDI5LjAzNiAyNi4wNzQ4IDI5Ljk5MiAyMy45OTk4IDM0Ljc0N1YxMy45ODdDMjMuOTk5OCAxMy43MjE4IDIzLjg5NDQgMTMuNDY3NCAyMy43MDY5IDEzLjI3OTlDMjMuNTE5NCAxMy4wOTI0IDIzLjI2NSAxMi45ODcgMjIuOTk5OCAxMi45ODdDMjIuNzM0NiAxMi45ODcgMjIuNDgwMiAxMy4wOTI0IDIyLjI5MjcgMTMuMjc5OUMyMi4xMDUyIDEzLjQ2NzQgMjEuOTk5OCAxMy43MjE4IDIxLjk5OTggMTMuOTg3VjM0Ljc0N0MxOS45MjQ4IDI5Ljk5MiAxNi45NTc4IDI5LjAzNiAxNS4wMTg4IDI5SDE0LjkyNDhDMTMuNjQzNyAyOS4wMjkyIDEyLjQwMzIgMjkuNDU1NSAxMS4zNzQ4IDMwLjIyQzExLjI3MjIgMzAuMzAyMSAxMS4xODY4IDMwLjQwMzUgMTEuMTIzNSAzMC41MTg2QzExLjA2MDIgMzAuNjMzNyAxMS4wMjAxIDMwLjc2MDEgMTEuMDA1NiAzMC44OTA3QzEwLjk5MTEgMzEuMDIxMiAxMS4wMDI1IDMxLjE1MzQgMTEuMDM5MSAzMS4yNzk1QzExLjA3NTcgMzEuNDA1NyAxMS4xMzY3IDMxLjUyMzQgMTEuMjE4OCAzMS42MjZDMTEuMzAwOSAzMS43Mjg2IDExLjQwMjQgMzEuODE0IDExLjUxNzQgMzEuODc3M0MxMS42MzI1IDMxLjk0MDYgMTEuNzU4OSAzMS45ODA3IDExLjg4OTUgMzEuOTk1MkMxMi4wMjAxIDMyLjAwOTcgMTIuMTUyMiAzMS45OTgzIDEyLjI3ODQgMzEuOTYxN0MxMi40MDQ1IDMxLjkyNTEgMTIuNTIyMiAzMS44NjQxIDEyLjYyNDggMzEuNzgyQzEzLjI5NjEgMzEuMjk3MyAxNC4wOTcyIDMxLjAyNDkgMTQuOTI0OCAzMUgxNC45ODE4QzE4LjU0ODggMzEuMDY1IDIxLjEwODggMzUuNDgyIDIyLjAwNzggNDMuMTE3QzIyLjAzNjcgNDMuMzYwMSAyMi4xNTM4IDQzLjU4NDEgMjIuMzM2OCA0My43NDY2QzIyLjUxOTggNDMuOTA5MSAyMi43NTYgNDMuOTk4OSAyMy4wMDA4IDQzLjk5ODlDMjMuMjQ1NiA0My45OTg5IDIzLjQ4MTggNDMuOTA5MSAyMy42NjQ4IDQzLjc0NjZDMjMuODQ3OCA0My41ODQxIDIzLjk2NDkgNDMuMzYwMSAyMy45OTM4IDQzLjExN0MyNC44OTM4IDM1LjQ4MiAyNy40NTI4IDMxLjA2NSAzMS4wMTk4IDMxSDMxLjA3NjhDMzEuOTA0MyAzMS4wMjgyIDMyLjcwNDcgMzEuMzAxNCAzMy4zNzY4IDMxLjc4NUMzMy41ODQ3IDMxLjk0NTggMzMuODQ3NiAzMi4wMTgzIDM0LjEwODUgMzEuOTg2OEMzNC4zNjk1IDMxLjk1NTIgMzQuNjA3NSAzMS44MjIyIDM0Ljc3MTIgMzEuNjE2NUMzNC45MzQ4IDMxLjQxMDggMzUuMDEwOSAzMS4xNDkgMzQuOTgyOSAzMC44ODc2QzM0Ljk1NSAzMC42MjYzIDM0LjgyNTIgMzAuMzg2NCAzNC42MjE4IDMwLjIyTDM0LjYyNDggMzAuMjE5WiIgZmlsbD0iIzI2MjYzMyIvPjxwYXRoIGQ9Ik0xMC4yNTMxIDQyQzEwLjA5MSA0MS45OTk5IDkuOTMxNDUgNDEuOTYwNCA5Ljc4ODA2IDQxLjg4NUw3LjAwMDA2IDQwLjQyIDQuMjEyMDYgNDEuODg1QzQuMDQ2OTQgNDEuOTcxNSAzLjg2MDkxIDQyLjAxMDIgMy42NzQ5OCA0MS45OTY2IDMuNDg5MDUgNDEuOTgzMSAzLjMxMDYxIDQxLjkxNzggMy4xNTk4MiA0MS44MDgyIDMuMDA5MDIgNDEuNjk4NiAyLjg5MTg3IDQxLjU0OSAyLjgyMTU4IDQxLjM3NjMgMi43NTEzIDQxLjIwMzYgMi43MzA2OCA0MS4wMTQ3IDIuNzYyMDYgNDAuODMxTDMuMjk0MDYgMzcuNzMxIDEuMDM4MDYgMzUuNTMxQy45MDYwODIgMzUuNDAwNS44MTMwMzUgMzUuMjM1OC43NjkzMyAzNS4wNTU0LjcyNTYyNSAzNC44NzUuNzMyOTg2IDM0LjY4Ni43OTA1ODkgMzQuNTA5Ni44NDgxOTIgMzQuMzMzMi45NTM3NjQgMzQuMTc2MiAxLjA5NTQ5IDM0LjA1NjQgMS4yMzcyMSAzMy45MzY1IDEuNDA5NSAzMy44NTg1IDEuNTkzMDYgMzMuODMxTDQuNzA5MDYgMzMuMzc3IDYuMTA5MDYgMzAuNTUzQzYuMjAwNzQgMzAuMzk2OCA2LjMzMTY2IDMwLjI2NzMgNi40ODg4MyAzMC4xNzczIDYuNjQ2IDMwLjA4NzMgNi44MjM5NSAzMC4wNCA3LjAwNTA2IDMwLjA0IDcuMTg2MTYgMzAuMDQgNy4zNjQxMSAzMC4wODczIDcuNTIxMjggMzAuMTc3MyA3LjY3ODQ1IDMwLjI2NzMgNy44MDkzNyAzMC4zOTY4IDcuOTAxMDYgMzAuNTUzTDkuMjk2MDUgMzMuMzc3IDEyLjQxMjEgMzMuODMxQzEyLjU5NTYgMzMuODU4NSAxMi43Njc5IDMzLjkzNjUgMTIuOTA5NiAzNC4wNTY0IDEzLjA1MTMgMzQuMTc2MiAxMy4xNTY5IDM0LjMzMzIgMTMuMjE0NSAzNC41MDk2IDEzLjI3MjEgMzQuNjg2IDEzLjI3OTUgMzQuODc1IDEzLjIzNTggMzUuMDU1NCAxMy4xOTIxIDM1LjIzNTggMTMuMDk5IDM1LjQwMDUgMTIuOTY3MSAzNS41MzFMMTAuNzExMSAzNy43MzEgMTEuMjQzMSA0MC44MzFDMTEuMjY3NyA0MC45NzQ5IDExLjI2MDYgNDEuMTIyNCAxMS4yMjIyIDQxLjI2MzMgMTEuMTgzNyA0MS40MDQxIDExLjExNDkgNDEuNTM0OCAxMS4wMjA2IDQxLjY0NjMgMTAuOTI2MiA0MS43NTc3IDEwLjgwODYgNDEuODQ3MSAxMC42NzYgNDEuOTA4MiAxMC41NDM0IDQxLjk2OTMgMTAuMzk5MSA0Mi4wMDA2IDEwLjI1MzEgNDJ6TTI2LjI1MzEgMTJDMjYuMDkxIDExLjk5OTkgMjUuOTMxNSAxMS45NjA0IDI1Ljc4ODEgMTEuODg1TDIzLjAwMDEgMTAuNDIgMjAuMjEyMSAxMS44ODVDMjAuMDQ2OSAxMS45NzE1IDE5Ljg2MDkgMTIuMDEwMiAxOS42NzUgMTEuOTk2NiAxOS40ODkxIDExLjk4MzEgMTkuMzEwNiAxMS45MTc4IDE5LjE1OTggMTEuODA4MiAxOS4wMDkgMTEuNjk4NiAxOC44OTE5IDExLjU0OSAxOC44MjE2IDExLjM3NjMgMTguNzUxMyAxMS4yMDM2IDE4LjczMDcgMTEuMDE0OCAxOC43NjIxIDEwLjgzMUwxOS4yOTQxIDcuNzMwOTkgMTcuMDM4MSA1LjUzMDk5QzE2LjkwNjEgNS40MDA0OCAxNi44MTMgNS4yMzU4MSAxNi43NjkzIDUuMDU1NDIgMTYuNzI1NiA0Ljg3NTAzIDE2LjczMyA0LjY4NjA0IDE2Ljc5MDYgNC41MDk2IDE2Ljg0ODIgNC4zMzMxNiAxNi45NTM4IDQuMTc2MjMgMTcuMDk1NSA0LjA1NjM4IDE3LjIzNzIgMy45MzY1MyAxNy40MDk1IDMuODU4NDggMTcuNTkzMSAzLjgzMDk5TDIwLjcwOTEgMy4zNzY5OSAyMi4xMDAxLjU0NDk4NkMyMi4xOTE3LjM4ODgwOSAyMi4zMjI3LjI1OTMxMSAyMi40Nzk4LjE2OTMzIDIyLjYzNy4wNzkzNSAyMi44MTUuMDMyMDEyOSAyMi45OTYxLjAzMjAxMjkgMjMuMTc3Mi4wMzIwMTI5IDIzLjM1NTEuMDc5MzUgMjMuNTEyMy4xNjkzMyAyMy42Njk0LjI1OTMxMSAyMy44MDA0LjM4ODgwOSAyMy44OTIxLjU0NDk4NkwyNS4yODcxIDMuMzY4OTkgMjguNDAzMSAzLjgyMjk5QzI4LjU4NjYgMy44NTA0OCAyOC43NTg5IDMuOTI4NTMgMjguOTAwNiA0LjA0ODM4IDI5LjA0MjMgNC4xNjgyMyAyOS4xNDc5IDQuMzI1MTYgMjkuMjA1NSA0LjUwMTYgMjkuMjYzMSA0LjY3ODA0IDI5LjI3MDUgNC44NjcwMyAyOS4yMjY4IDUuMDQ3NDIgMjkuMTgzMSA1LjIyNzgxIDI5LjA5IDUuMzkyNDcgMjguOTU4MSA1LjUyMjk5TDI2LjcwMjEgNy43MjI5OSAyNy4yMzQxIDEwLjgyM0MyNy4yNTk5IDEwLjk2NjcgMjcuMjU0IDExLjExNDQgMjcuMjE2NiAxMS4yNTU2IDI3LjE3OTMgMTEuMzk2OCAyNy4xMTE1IDExLjUyODEgMjcuMDE4IDExLjY0MDIgMjYuOTI0NSAxMS43NTI0IDI2LjgwNzYgMTEuODQyOCAyNi42NzU0IDExLjkwNDkgMjYuNTQzMyAxMS45NjcxIDI2LjM5OTEgMTEuOTk5NSAyNi4yNTMxIDEyek00Mi4yNTMxIDQyQzQyLjA5MSA0MS45OTk5IDQxLjkzMTQgNDEuOTYwNCA0MS43ODgxIDQxLjg4NUwzOS4wMDAxIDQwLjQyIDM2LjIxMjEgNDEuODg1QzM2LjA0NjkgNDEuOTcxNSAzNS44NjA5IDQyLjAxMDIgMzUuNjc1IDQxLjk5NjYgMzUuNDg5IDQxLjk4MzEgMzUuMzEwNiA0MS45MTc4IDM1LjE1OTggNDEuODA4MiAzNS4wMDkgNDEuNjk4NiAzNC44OTE5IDQxLjU0OSAzNC44MjE2IDQxLjM3NjMgMzQuNzUxMyA0MS4yMDM2IDM0LjczMDcgNDEuMDE0OCAzNC43NjIxIDQwLjgzMUwzNS4yOTQxIDM3LjczMSAzMy4wMzgxIDM1LjUzMUMzMi45MDYxIDM1LjQwMDUgMzIuODEzIDM1LjIzNTggMzIuNzY5MyAzNS4wNTU0IDMyLjcyNTYgMzQuODc1IDMyLjczMyAzNC42ODYgMzIuNzkwNiAzNC41MDk2IDMyLjg0ODIgMzQuMzMzMiAzMi45NTM4IDM0LjE3NjIgMzMuMDk1NSAzNC4wNTY0IDMzLjIzNzIgMzMuOTM2NSAzMy40MDk1IDMzLjg1ODUgMzMuNTkzMSAzMy44MzFMMzYuNzA5MSAzMy4zNzcgMzguMTAwMSAzMC41NDVDMzguMTkxNyAzMC4zODg4IDM4LjMyMjcgMzAuMjU5MyAzOC40Nzk4IDMwLjE2OTMgMzguNjM3IDMwLjA3OTQgMzguODE0OSAzMC4wMzIgMzguOTk2MSAzMC4wMzIgMzkuMTc3MiAzMC4wMzIgMzkuMzU1MSAzMC4wNzk0IDM5LjUxMjMgMzAuMTY5MyAzOS42Njk0IDMwLjI1OTMgMzkuODAwNCAzMC4zODg4IDM5Ljg5MjEgMzAuNTQ1TDQxLjI4NzEgMzMuMzY5IDQ0LjQwMzEgMzMuODIzQzQ0LjU4NjYgMzMuODUwNSA0NC43NTg5IDMzLjkyODUgNDQuOTAwNiAzNC4wNDg0IDQ1LjA0MjMgMzQuMTY4MiA0NS4xNDc5IDM0LjMyNTIgNDUuMjA1NSAzNC41MDE2IDQ1LjI2MzEgMzQuNjc4IDQ1LjI3MDUgMzQuODY3IDQ1LjIyNjggMzUuMDQ3NCA0NS4xODMxIDM1LjIyNzggNDUuMDkgMzUuMzkyNSA0NC45NTgxIDM1LjUyM0w0Mi43MDIxIDM3LjcyMyA0My4yMzQxIDQwLjgyM0M0My4yNTk5IDQwLjk2NjcgNDMuMjU0IDQxLjExNDQgNDMuMjE2NiA0MS4yNTU2IDQzLjE3OTMgNDEuMzk2OCA0My4xMTE1IDQxLjUyODEgNDMuMDE4IDQxLjY0MDIgNDIuOTI0NSA0MS43NTI0IDQyLjgwNzYgNDEuODQyOCA0Mi42NzU0IDQxLjkwNDkgNDIuNTQzMyA0MS45NjcxIDQyLjM5OTEgNDEuOTk5NSA0Mi4yNTMxIDQyeiIgZmlsbD0iIzE2QjM3OCIvPjxwYXRoIGQ9Ik05IDE0QzguMjA0MzUgMTQgNy40NDEyOSAxMy42ODM5IDYuODc4NjggMTMuMTIxMyA2LjMxNjA3IDEyLjU1ODcgNiAxMS43OTU2IDYgMTEgNiAxMC43MzQ4IDUuODk0NjQgMTAuNDgwNCA1LjcwNzExIDEwLjI5MjkgNS41MTk1NyAxMC4xMDU0IDUuMjY1MjIgMTAgNSAxMCA0LjczNDc4IDEwIDQuNDgwNDMgMTAuMTA1NCA0LjI5Mjg5IDEwLjI5MjkgNC4xMDUzNiAxMC40ODA0IDQgMTAuNzM0OCA0IDExIDQgMTEuNzk1NiAzLjY4MzkzIDEyLjU1ODcgMy4xMjEzMiAxMy4xMjEzIDIuNTU4NzEgMTMuNjgzOSAxLjc5NTY1IDE0IDEgMTQgLjczNDc4NCAxNCAuNDgwNDMgMTQuMTA1NC4yOTI4OTMgMTQuMjkyOS4xMDUzNTcgMTQuNDgwNCAwIDE0LjczNDggMCAxNSAwIDE1LjI2NTIuMTA1MzU3IDE1LjUxOTYuMjkyODkzIDE1LjcwNzEuNDgwNDMgMTUuODk0Ni43MzQ3ODQgMTYgMSAxNiAxLjc5NTY1IDE2IDIuNTU4NzEgMTYuMzE2MSAzLjEyMTMyIDE2Ljg3ODcgMy42ODM5MyAxNy40NDEzIDQgMTguMjA0NCA0IDE5IDQgMTkuMjY1MiA0LjEwNTM2IDE5LjUxOTYgNC4yOTI4OSAxOS43MDcxIDQuNDgwNDMgMTkuODk0NiA0LjczNDc4IDIwIDUgMjAgNS4yNjUyMiAyMCA1LjUxOTU3IDE5Ljg5NDYgNS43MDcxMSAxOS43MDcxIDUuODk0NjQgMTkuNTE5NiA2IDE5LjI2NTIgNiAxOSA2IDE4LjIwNDQgNi4zMTYwNyAxNy40NDEzIDYuODc4NjggMTYuODc4NyA3LjQ0MTI5IDE2LjMxNjEgOC4yMDQzNSAxNiA5IDE2IDkuMjY1MjIgMTYgOS41MTk1NyAxNS44OTQ2IDkuNzA3MTEgMTUuNzA3MSA5Ljg5NDY0IDE1LjUxOTYgMTAgMTUuMjY1MiAxMCAxNSAxMCAxNC43MzQ4IDkuODk0NjQgMTQuNDgwNCA5LjcwNzExIDE0LjI5MjkgOS41MTk1NyAxNC4xMDU0IDkuMjY1MjIgMTQgOSAxNHpNNDUgMTMuOTg3QzQ0LjIwNDQgMTMuOTg3IDQzLjQ0MTMgMTMuNjcwOSA0Mi44Nzg3IDEzLjEwODMgNDIuMzE2MSAxMi41NDU3IDQyIDExLjc4MjYgNDIgMTAuOTg3IDQyIDEwLjcyMTggNDEuODk0NiAxMC40Njc0IDQxLjcwNzEgMTAuMjc5OSA0MS41MTk2IDEwLjA5MjQgNDEuMjY1MiA5Ljk4NyA0MSA5Ljk4NyA0MC43MzQ4IDkuOTg3IDQwLjQ4MDQgMTAuMDkyNCA0MC4yOTI5IDEwLjI3OTkgNDAuMTA1NCAxMC40Njc0IDQwIDEwLjcyMTggNDAgMTAuOTg3IDQwIDExLjc4MjYgMzkuNjgzOSAxMi41NDU3IDM5LjEyMTMgMTMuMTA4MyAzOC41NTg3IDEzLjY3MDkgMzcuNzk1NiAxMy45ODcgMzcgMTMuOTg3IDM2LjczNDggMTMuOTg3IDM2LjQ4MDQgMTQuMDkyNCAzNi4yOTI5IDE0LjI3OTkgMzYuMTA1NCAxNC40Njc0IDM2IDE0LjcyMTggMzYgMTQuOTg3IDM2IDE1LjI1MjIgMzYuMTA1NCAxNS41MDY2IDM2LjI5MjkgMTUuNjk0MSAzNi40ODA0IDE1Ljg4MTYgMzYuNzM0OCAxNS45ODcgMzcgMTUuOTg3IDM3Ljc5NTYgMTUuOTg3IDM4LjU1ODcgMTYuMzAzMSAzOS4xMjEzIDE2Ljg2NTcgMzkuNjgzOSAxNy40MjgzIDQwIDE4LjE5MTMgNDAgMTguOTg3IDQwIDE5LjI1MjIgNDAuMTA1NCAxOS41MDY2IDQwLjI5MjkgMTkuNjk0MSA0MC40ODA0IDE5Ljg4MTYgNDAuNzM0OCAxOS45ODcgNDEgMTkuOTg3IDQxLjI2NTIgMTkuOTg3IDQxLjUxOTYgMTkuODgxNiA0MS43MDcxIDE5LjY5NDEgNDEuODk0NiAxOS41MDY2IDQyIDE5LjI1MjIgNDIgMTguOTg3IDQyIDE4LjE5MTMgNDIuMzE2MSAxNy40MjgzIDQyLjg3ODcgMTYuODY1NyA0My40NDEzIDE2LjMwMzEgNDQuMjA0NCAxNS45ODcgNDUgMTUuOTg3IDQ1LjI2NTIgMTUuOTg3IDQ1LjUxOTYgMTUuODgxNiA0NS43MDcxIDE1LjY5NDEgNDUuODk0NiAxNS41MDY2IDQ2IDE1LjI1MjIgNDYgMTQuOTg3IDQ2IDE0LjcyMTggNDUuODk0NiAxNC40Njc0IDQ1LjcwNzEgMTQuMjc5OSA0NS41MTk2IDE0LjA5MjQgNDUuMjY1MiAxMy45ODcgNDUgMTMuOTg3eiIgZmlsbD0iI0U2QTExNyIvPjxwYXRoIGQ9Ik0xOS4wMDAyIDI4QzE4LjgxMzYgMjguMDAxIDE4LjYzMDMgMjcuOTQ5NyAxOC40NzEzIDI3Ljg1MiAxOC4zMTIyIDI3Ljc1NDMgMTguMTgzNyAyNy42MTQgMTguMTAwMiAyNy40NDcgMTQuNDUzMiAyMC4xNTMgOS4yMDgyMyAxOS45OSA4Ljk4NjIzIDE5Ljk4NyA4Ljg1NDkxIDE5Ljk4NjQgOC43MjQ5OSAxOS45NTk5IDguNjAzODkgMTkuOTA5MSA4LjQ4Mjc5IDE5Ljg1ODMgOC4zNzI4OCAxOS43ODQyIDguMjgwNDQgMTkuNjkwOSA4LjA5Mzc1IDE5LjUwMjUgNy45ODk1NCAxOS4yNDc3IDcuOTkwNzMgMTguOTgyNSA3Ljk5MTkzIDE4LjcxNzMgOC4wOTg0MyAxOC40NjM0IDguMjg2ODEgMTguMjc2NyA4LjQ3NTE5IDE4LjA5IDguNzMwMDIgMTcuOTg1OCA4Ljk5NTIzIDE3Ljk4NyA5LjI2NTIzIDE3Ljk4NyAxNS42NjAyIDE4LjA5OSAxOS44ODgyIDI2LjU1MyAxOS45NjQyIDI2LjcwNDkgMjAuMDAwMSAyNi44NzM4IDE5Ljk5MjggMjcuMDQzNSAxOS45ODU0IDI3LjIxMzIgMTkuOTM0OSAyNy4zNzgzIDE5Ljg0NiAyNy41MjMgMTkuNzU3MiAyNy42Njc4IDE5LjYzMjkgMjcuNzg3NiAxOS40ODQ5IDI3Ljg3MTEgMTkuMzM2OSAyNy45NTQ1IDE5LjE3MDEgMjcuOTk4OSAxOS4wMDAyIDI4ek0yNyAyOEMyNi44Mjk2IDI3Ljk5OTkgMjYuNjYyIDI3Ljk1NjMgMjYuNTEzMiAyNy44NzMzIDI2LjM2NDQgMjcuNzkwMiAyNi4yMzkzIDI3LjY3MDYgMjYuMTQ5OCAyNy41MjU2IDI2LjA2MDIgMjcuMzgwNiAyNi4wMDkyIDI3LjIxNTIgMjYuMDAxNSAyNy4wNDUgMjUuOTkzOCAyNi44NzQ4IDI2LjAyOTggMjYuNzA1NCAyNi4xMDYgMjYuNTUzIDMwLjMzMyAxOC4xIDM2LjcyOCAxNy45ODggMzcgMTcuOTg3IDM3LjI2NTIgMTcuOTg3IDM3LjUxOTUgMTguMDkyNCAzNy43MDcxIDE4LjI3OTkgMzcuODk0NiAxOC40Njc0IDM4IDE4LjcyMTggMzggMTguOTg3IDM4IDE5LjI1MjIgMzcuODk0NiAxOS41MDY2IDM3LjcwNzEgMTkuNjk0MSAzNy41MTk1IDE5Ljg4MTYgMzcuMjY1MiAxOS45ODcgMzcgMTkuOTg3IDM2Ljc4NSAxOS45ODcgMzEuNTQgMjAuMTUzIDI3Ljg5MyAyNy40NDcgMjcuODEgMjcuNjEyOSAyNy42ODI2IDI3Ljc1MjUgMjcuNTI0OSAyNy44NTAyIDI3LjM2NzIgMjcuOTQ3OCAyNy4xODU0IDI3Ljk5OTcgMjcgMjh6IiBmaWxsPSIjMjYyNjMzIi8+PC9zdmc+');
+  --icon-Star: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjUiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0yLjI0MDgxIDQuODQ5MjNDMi42MDAyMyA0LjQyOTkgMy4yMzE1MyA0LjM4MTM0IDMuNjUwODYgNC43NDA3Nkw3LjE1MDg2IDcuNzQwNzZDNy41NzAxOCA4LjEwMDE4IDcuNjE4NzUgOC43MzE0OCA3LjI1OTMyIDkuMTUwODFDNi44OTk5IDkuNTcwMTQgNi4yNjg2IDkuNjE4NyA1Ljg0OTI4IDkuMjU5MjdMMi4zNDkyOCA2LjI1OTI3QzEuOTI5OTUgNS44OTk4NSAxLjg4MTM5IDUuMjY4NTUgMi4yNDA4MSA0Ljg0OTIzWk0xMS41IDcuMDIwODFDMTEuMTM3IDcuMDIwODEgMTAuODA1MiA3LjIyNTkgMTAuNjQyOSA3LjU1MDU3TDguODc0NTUgMTEuMDg3Mkw0Ljk4NzA5IDExLjY1MjdDNC42MjYxNiAxMS43MDUyIDQuMzI2MzEgMTEuOTU4IDQuMjEzNiAxMi4zMDQ5QzQuMTAwOSAxMi42NTE4IDQuMTk0ODcgMTMuMDMyNiA0LjQ1NjAyIDEzLjI4NzJMNy4yODYxOSAxNi4wNDY2TDYuNjQ5NTkgMTkuOTM2OUM2LjU5MDc5IDIwLjI5NjIgNi43NDA3MSAyMC42NTc4IDcuMDM2NTEgMjAuODcwMkM3LjMzMjMxIDIxLjA4MjUgNy43MjI4OSAyMS4xMDg5IDguMDQ0NTMgMjAuOTM4MkwxMS41IDE5LjEwNDdMMTQuOTU1NSAyMC45MzgyQzE1LjI3NzIgMjEuMTA4OSAxNS42Njc4IDIxLjA4MjUgMTUuOTYzNiAyMC44NzAyQzE2LjI1OTQgMjAuNjU3OCAxNi40MDkzIDIwLjI5NjIgMTYuMzUwNSAxOS45MzY5TDE1LjcxMzkgMTYuMDQ2NkwxOC41NDQgMTMuMjg3MkMxOC44MDUyIDEzLjAzMjYgMTguODk5MiAxMi42NTE4IDE4Ljc4NjUgMTIuMzA0OUMxOC42NzM4IDExLjk1OCAxOC4zNzM5IDExLjcwNTIgMTguMDEzIDExLjY1MjdMMTQuMTI1NSAxMS4wODcyTDEyLjM1NzIgNy41NTA1N0MxMi4xOTQ5IDcuMjI1OSAxMS44NjMgNy4wMjA4MSAxMS41IDcuMDIwODFaTTEwLjM2NSAxMi4zOTIxTDExLjUgMTAuMTIyTDEyLjYzNTEgMTIuMzkyMUMxMi43NzUzIDEyLjY3MjYgMTMuMDQzOSAxMi44NjY3IDEzLjM1NDMgMTIuOTExOUwxNS44MTYyIDEzLjI3TDE0LjAxODUgMTUuMDIyN0MxMy43OTQ2IDE1LjI0MSAxMy42OTEzIDE1LjU1NDkgMTMuNzQxOCAxNS44NjM2TDE0LjE0NjkgMTguMzM5M0wxMS45NDkyIDE3LjE3MzJDMTEuNjY4MyAxNy4wMjQyIDExLjMzMTggMTcuMDI0MiAxMS4wNTA4IDE3LjE3MzJMOC44NTMxNyAxOC4zMzkzTDkuMjU4MjkgMTUuODYzNkM5LjMwODggMTUuNTU0OSA5LjIwNTUgMTUuMjQxIDguOTgxNTUgMTUuMDIyN0w3LjE4MzkxIDEzLjI3TDkuNjQ1NzkgMTIuOTExOUM5Ljk1NjEzIDEyLjg2NjcgMTAuMjI0OCAxMi42NzI2IDEwLjM2NSAxMi4zOTIxWk0xOS4zNDkyIDQuNzQwNzZDMTkuNzY4NiA0LjM4MTM0IDIwLjM5OTkgNC40Mjk5IDIwLjc1OTMgNC44NDkyM0MyMS4xMTg3IDUuMjY4NTUgMjEuMDcwMSA1Ljg5OTg1IDIwLjY1MDggNi4yNTkyN0wxNy4xNTA4IDkuMjU5MjdDMTYuNzMxNSA5LjYxODcgMTYuMTAwMiA5LjU3MDE0IDE1Ljc0MDggOS4xNTA4MUMxNS4zODE0IDguNzMxNDggMTUuNDI5OSA4LjEwMDE4IDE1Ljg0OTIgNy43NDA3NkwxOS4zNDkyIDQuNzQwNzZaTTIyLjI3NTMgMTkuNjMxNkMyMS45MjY1IDIwLjA1OTggMjEuMjk2NiAyMC4xMjQxIDIwLjg2ODQgMTkuNzc1M0wxOC4zNjg0IDE3LjczODZDMTcuOTQwMyAxNy4zODk3IDE3Ljg3NiAxNi43NTk4IDE4LjIyNDggMTYuMzMxN0MxOC41NzM2IDE1LjkwMzUgMTkuMjAzNSAxNS44MzkyIDE5LjYzMTcgMTYuMTg4TDIyLjEzMTcgMTguMjI0N0MyMi41NTk5IDE4LjU3MzYgMjIuNjI0MiAxOS4yMDM1IDIyLjI3NTMgMTkuNjMxNlpNMi4xMzE2NSAxOS43NzUzQzEuNzAzNDcgMjAuMTI0MSAxLjA3MzU4IDIwLjA1OTggMC43MjQ3NTIgMTkuNjMxNkMwLjM3NTkxOSAxOS4yMDM1IDAuNDQwMjM4IDE4LjU3MzYgMC44Njg0MTMgMTguMjI0N0wzLjM2ODQxIDE2LjE4OEMzLjc5NjU5IDE1LjgzOTIgNC40MjY0OCAxNS45MDM1IDQuNzc1MzEgMTYuMzMxN0M1LjEyNDE0IDE2Ljc1OTggNS4wNTk4MiAxNy4zODk3IDQuNjMxNjUgMTcuNzM4NkwyLjEzMTY1IDE5Ljc3NTNaIiBmaWxsPSIjMTZCMzc4Ii8+PC9zdmc+');
   --icon-Tick: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTExLjYxODMwNjksNC42NzcwMjg0NyBDMTEuNzk2Njc4OSw0LjQ2NjIyNTE3IDEyLjExMjE2NzgsNC40Mzk5MzQ0MyAxMi4zMjI5NzExLDQuNjE4MzA2NDUgQzEyLjUzMzc3NDQsNC43OTY2Nzg0OCAxMi41NjAwNjUyLDUuMTEyMTY3NDEgMTIuMzgxNjkzMSw1LjMyMjk3MDcxIEw2LjUzMDY4ODI3LDEyLjIzNzc5NDYgTDMuNjQ2NDQ2NjEsOS4zNTM1NTI5OCBDMy40NTExODQ0Niw5LjE1ODI5MDg0IDMuNDUxMTg0NDYsOC44NDE3MDgzNSAzLjY0NjQ0NjYxLDguNjQ2NDQ2MiBDMy44NDE3MDg3Niw4LjQ1MTE4NDA2IDQuMTU4MjkxMjQsOC40NTExODQwNiA0LjM1MzU1MzM5LDguNjQ2NDQ2MiBMNi40NjkzMTE3MywxMC43NjIyMDQ1IEwxMS42MTgzMDY5LDQuNjc3MDI4NDcgWiIgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIi8+PC9zdmc+');
   --icon-TickSolid: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZD0ibSA2MS40NCwwIGMgMzMuOTMzLDAgNjEuNDQxLDI3LjUwNyA2MS40NDEsNjEuNDM5IDAsMzMuOTMzIC0yNy41MDgsNjEuNDQgLTYxLjQ0MSw2MS40NCBDIDI3LjUwOCwxMjIuODggMCw5NS4zNzIgMCw2MS40MzkgMCwyNy41MDcgMjcuNTA4LDAgNjEuNDQsMCBaIE0gMzQuMTA2LDY3LjY3OCAzNC4wOTEsNjcuNjY0IGMgLTAuNzg1LC0wLjcxOCAtMS4yMDcsLTEuNjg1IC0xLjI1NiwtMi42NjkgLTAuMDQ5LC0wLjk4MiAwLjI3NSwtMS45ODUgMC45ODQsLTIuNzc3IDAuMDEsLTAuMDExIDAuMDE5LC0wLjAyMSAwLjAyOSwtMC4wMzEgMC43MTcsLTAuNzg0IDEuNjg0LC0xLjIwNyAyLjY2OCwtMS4yNTYgMC45ODksLTAuMDQ5IDEuOTk4LDAuMjggMi43OTIsMC45OTggTCA1Mi4yNjQsNzMuNjc3IDgzLjM1Myw0MS4xMTggdiAwIGMgMC43NCwtMC43NzYgMS43MjMsLTEuMTggMi43MTksLTEuMjA0IDAuOTkyLC0wLjAyNSAxLjk5NCwwLjMyOSAyLjc3MSwxLjA2NyB2IDEwZS00IGMgMC43NzcsMC43MzkgMS4xOCwxLjcyNCAxLjIwNSwyLjcxOCAwLjAyNSwwLjk5MyAtMC4zMywxLjk5NyAtMS4wNjgsMi43NzMgTCA1NS4yNzksODEuNzY5IGMgLTAuMDIzLDAuMDI0IC0wLjA0OCwwLjA0NyAtMC4wNzMsMC4wNjcgLTAuNzE1LDAuNzE1IC0xLjY0OSwxLjA5NSAtMi41OTgsMS4xMyAtMC45NzQsMC4wMzcgLTEuOTYzLC0wLjI5MyAtMi43NDQsLTEgTCAzNC4xMTgsNjcuNjg4IFoiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZmlsbC1ydWxlPSJldmVub2RkIiB0cmFuc2Zvcm09Im1hdHJpeCguMTMwMiAwIDAgLjEzMDIgMCAwKSIvPjwvc3ZnPg==');
   --icon-Undo: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTIuMzU1MjI1NjIsNSBMNi41LDUgQzYuNzc2MTQyMzcsNSA3LDUuMjIzODU3NjMgNyw1LjUgQzcsNS43NzYxNDIzNyA2Ljc3NjE0MjM3LDYgNi41LDYgTDEuNTEwMTA5OTksNiBDMS40ODY3NTIwNiw2LjAwMDQ5MTk4IDEuNDYzMjU2NTksNS45OTkzNDQxNCAxLjQzOTc5Mzg5LDUuOTk2NDk5MjUgQzEuMzYwMjgyMTYsNS45ODY4NzA1OCAxLjI4NjUwNTA1LDUuOTU4NjY1NjMgMS4yMjI5NzM2NSw1LjkxNjMwNDg0IEMxLjA5OTIxNTYzLDUuODM0MTc1NDcgMS4wMjE4NjQ3MSw1LjcwMzQ3ODggMS4wMDM5MTQxNyw1LjU2Mjg2NjI5IEMxLjAwMTgxMDM5LDUuNTQ2MDk2MDIgMS4wMDA1MzcxOCw1LjUyOTA2NzQgMS4wMDAxMzcwNCw1LjUxMTgyMjkyIEMwLjk5OTg1Mzk4Nyw1LjUwNDI3MzIxIDAuOTk5ODQwMjk5LDUuNDk2NzA0NiAxLDUuNDg5MTI2OTMgTDEsMC41IEMxLDAuMjIzODU3NjI1IDEuMjIzODU3NjMsMCAxLjUsMCBDMS43NzYxNDIzNywwIDIsMC4yMjM4NTc2MjUgMiwwLjUgTDIsMy43NzQ2OTMxNiBDMy4zNjg1NDA5LDIuMDM5NDAzNDMgNS4zMjkwNzU2LDEgNy41LDEgQzExLjY0MjE0MjQsMSAxNSw0LjM1Nzg1NzYzIDE1LDguNSBDMTUsMTIuNjQyMTQyNCAxMS42NDIxNDI0LDE2IDcuNSwxNiBDNy4yMjM4NTc2MywxNiA3LDE1Ljc3NjE0MjQgNywxNS41IEM3LDE1LjIyMzg1NzYgNy4yMjM4NTc2MywxNSA3LjUsMTUgQzExLjA4OTg1NzYsMTUgMTQsMTIuMDg5ODU3NiAxNCw4LjUgQzE0LDQuOTEwMTQyMzcgMTEuMDg5ODU3NiwyIDcuNSwyIEM1LjQxNTc0NjU0LDIgMy41NDU1NTE2MywzLjEzMDY0MDcgMi4zNTUyMjU2Miw1IFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg==');
   --icon-Validation: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTE1LDEwIEwxMSwxMCBMMTEsMTIuNSBDMTEsMTIuNzc2MTQyNCAxMC43NzYxNDI0LDEzIDEwLjUsMTMgTDUuNSwxMyBDNS4yMjM4NTc2MywxMyA1LDEyLjc3NjE0MjQgNSwxMi41IEw1LDEwIEwxLDEwIEwxLDE0LjUgQzEsMTQuNzc1ODU3NiAxLjIyNDE0MjM3LDE1IDEuNSwxNSBMMTQuNSwxNSBDMTQuNzc1ODU3NiwxNSAxNSwxNC43NzU4NTc2IDE1LDE0LjUgTDE1LDEwIFogTTE1LDkgTDE1LDEuNSBDMTUsMS4yMjQxNDIzNyAxNC43NzU4NTc2LDEgMTQuNSwxIEwxLjUsMSBDMS4yMjQxNDIzNywxIDEsMS4yMjQxNDIzNyAxLDEuNSBMMSw5IEw1LjUsOSBDNS43NzYxNDIzNyw5IDYsOS4yMjM4NTc2MyA2LDkuNSBMNiwxMiBMMTAsMTIgTDEwLDkuNSBDMTAsOS4yMjM4NTc2MyAxMC4yMjM4NTc2LDkgMTAuNSw5IEwxNSw5IFogTTE0LjUsMTYgTDEuNSwxNiBDMC42NzE4NTc2MjUsMTYgMCwxNS4zMjgxNDI0IDAsMTQuNSBMMCwxLjUgQzAsMC42NzE4NTc2MjUgMC42NzE4NTc2MjUsMCAxLjUsMCBMMTQuNSwwIEMxNS4zMjgxNDI0LDAgMTYsMC42NzE4NTc2MjUgMTYsMS41IEwxNiwxNC41IEMxNiwxNS4zMjgxNDI0IDE1LjMyODE0MjQsMTYgMTQuNSwxNiBaIE0xMS4xNDY0NDY2LDMuMTQ2NDQ2NjEgQzExLjM0MTcwODgsMi45NTExODQ0NiAxMS42NTgyOTEyLDIuOTUxMTg0NDYgMTEuODUzNTUzNCwzLjE0NjQ0NjYxIEMxMi4wNDg4MTU1LDMuMzQxNzA4NzYgMTIuMDQ4ODE1NSwzLjY1ODI5MTI0IDExLjg1MzU1MzQsMy44NTM1NTMzOSBMNy44NTM1NTMzOSw3Ljg1MzU1MzM5IEM3LjY1ODI5MTI0LDguMDQ4ODE1NTQgNy4zNDE3MDg3Niw4LjA0ODgxNTU0IDcuMTQ2NDQ2NjEsNy44NTM1NTMzOSBMNS4xNDY0NDY2MSw1Ljg1MzU1MzM5IEM0Ljk1MTE4NDQ2LDUuNjU4MjkxMjQgNC45NTExODQ0Niw1LjM0MTcwODc2IDUuMTQ2NDQ2NjEsNS4xNDY0NDY2MSBDNS4zNDE3MDg3Niw0Ljk1MTE4NDQ2IDUuNjU4MjkxMjQsNC45NTExODQ0NiA1Ljg1MzU1MzM5LDUuMTQ2NDQ2NjEgTDcuNSw2Ljc5Mjg5MzIyIEwxMS4xNDY0NDY2LDMuMTQ2NDQ2NjEgWiIgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIi8+PC9zdmc+');
   --icon-Video: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTE0LjUsMSBDMTUuMzI4NDI3MSwxIDE2LDEuNjcxNTcyODggMTYsMi41IEwxNiwyLjUgTDE2LDEzLjUgQzE2LDE0LjMyODQyNzEgMTUuMzI4NDI3MSwxNSAxNC41LDE1IEwxNC41LDE1IEwxLjUsMTUgQzAuNjcxNTcyODc1LDE1IDAsMTQuMzI4NDI3MSAwLDEzLjUgTDAsMTMuNSBMMCwyLjUgQzAsMS42NzE1NzI4OCAwLjY3MTU3Mjg3NSwxIDEuNSwxIEwxLjUsMSBaIE0xNC41LDIgTDEuNSwyIEMxLjIyMzg1NzYzLDIgMSwyLjIyMzg1NzYzIDEsMi41IEwxLDIuNSBMMSwxMy41IEMxLDEzLjc3NjE0MjQgMS4yMjM4NTc2MywxNCAxLjUsMTQgTDEuNSwxNCBMMTQuNSwxNCBDMTQuNzc2MTQyNCwxNCAxNSwxMy43NzYxNDI0IDE1LDEzLjUgTDE1LDEzLjUgTDE1LDIuNSBDMTUsMi4yMjM4NTc2MyAxNC43NzYxNDI0LDIgMTQuNSwyIEwxNC41LDIgWiBNNSw0LjUgQzUsNC4xMDU0NTQ4NiA1LjQzNTU3NTIxLDMuODY2MzQ3NjQgNS43Njg0Mzc3NSw0LjA3ODE2OTI2IEw1Ljc2ODQzNzc1LDQuMDc4MTY5MjYgTDExLjI2ODQzNzcsNy41NzgxNjkyNiBDMTEuNTc3MTg3NCw3Ljc3NDY0NjMyIDExLjU3NzE4NzQsOC4yMjUzNTM2OCAxMS4yNjg0Mzc3LDguNDIxODMwNzQgTDExLjI2ODQzNzcsOC40MjE4MzA3NCBMNS43Njg0Mzc3NSwxMS45MjE4MzA3IEM1LjQzNTU3NTIxLDEyLjEzMzY1MjQgNSwxMS44OTQ1NDUxIDUsMTEuNSBMNSwxMS41IFogTTYsNS40MSBMNiwxMC41ODkgTDEwLjA2OSw4IEw2LDUuNDEgWiIgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIi8+PC9zdmc+');
+  --icon-VideoPlay: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzkiIGhlaWdodD0iMzUiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGZpbGw9IiNmZmYiPjxwYXRoIGQ9Ik00LjA2MjUgMy4yNUMzLjQwNjY2IDMuMjUgMi44NzUgMy43ODE2NiAyLjg3NSA0LjQzNzVWMzAuNTYyNUMyLjg3NSAzMS4yMTgzIDMuNDA2NjYgMzEuNzUgNC4wNjI1IDMxLjc1SDM0LjkzNzVDMzUuNTkzMyAzMS43NSAzNi4xMjUgMzEuMjE4MyAzNi4xMjUgMzAuNTYyNVY0LjQzNzVDMzYuMTI1IDMuNzgxNjYgMzUuNTkzMyAzLjI1IDM0LjkzNzUgMy4yNUg0LjA2MjVaTTAuNSA0LjQzNzVDMC41IDIuNDY5OTkgMi4wOTQ5OSAwLjg3NSA0LjA2MjUgMC44NzVIMzQuOTM3NUMzNi45MDUgMC44NzUgMzguNSAyLjQ2OTk5IDM4LjUgNC40Mzc1VjMwLjU2MjVDMzguNSAzMi41MyAzNi45MDUgMzQuMTI1IDM0LjkzNzUgMzQuMTI1SDQuMDYyNUMyLjA5NDk5IDM0LjEyNSAwLjUgMzIuNTMgMC41IDMwLjU2MjVWNC40Mzc1WiIvPjxwYXRoIGQ9Ik0xMi45OTExIDguMTQ2NTNDMTMuMzcxIDcuOTM3OTcgMTMuODM0NCA3Ljk1Mjk2IDE0LjIgOC4xODU2NUwyNy4yNjI1IDE2LjQ5ODJDMjcuNjA1MSAxNi43MTYxIDI3LjgxMjUgMTcuMDk0IDI3LjgxMjUgMTcuNUMyNy44MTI1IDE3LjkwNiAyNy42MDUxIDE4LjI4MzkgMjcuMjYyNSAxOC41MDE5TDE0LjIgMjYuODE0NEMxMy44MzQ0IDI3LjA0NyAxMy4zNzEgMjcuMDYyIDEyLjk5MTEgMjYuODUzNUMxMi42MTExIDI2LjY0NDkgMTIuMzc1IDI2LjI0NTkgMTIuMzc1IDI1LjgxMjVWOS4xODc1QzEyLjM3NSA4Ljc1NDA5IDEyLjYxMTEgOC4zNTUwOSAxMi45OTExIDguMTQ2NTNaTTE0Ljc1IDExLjM1MDdWMjMuNjQ5M0wyNC40MTMxIDE3LjVMMTQuNzUgMTEuMzUwN1oiLz48L2c+PC9zdmc+');
+  --icon-VideoPlay2: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjUiIGhlaWdodD0iMjUiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTYuNSA3LjI0MTA1QzYuNSA1LjY5NzQ3IDguMTc0NDMgNC43MzU3MyA5LjUwNzc0IDUuNTEzNDlMMTguNTIzMSAxMC43NzI1QzE5Ljg0NjEgMTEuNTQ0MiAxOS44NDYxIDEzLjQ1NTggMTguNTIzMSAxNC4yMjc2TDkuNTA3NzQgMTkuNDg2NUM4LjE3NDQzIDIwLjI2NDMgNi41IDE5LjMwMjYgNi41IDE3Ljc1OVY3LjI0MTA1Wk0xNy41MTU0IDEyLjVMOC41IDcuMjQxMDVWMTcuNzU5TDE3LjUxNTQgMTIuNVoiIGZpbGw9IiNmZmYiLz48L3N2Zz4=');
   --icon-Warning: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMTYiIHdpZHRoPSIxNiI+PHBhdGggZD0ibSA2NS40NSwxLjk3MiA1NS41OTQsODcuMzIzIGMgMS42NzMsMi42MyAzLjExNyw4LjAxNiAwLDguMDE2IEggMS44MzcgYyAtMy4xMTgsMCAtMS42NzYsLTUuMzg2IDAsLTguMDE2IEwgNTcuNDMxLDEuOTcyIGMgMS42NzUsLTIuNjMgNi4zNDMsLTIuNjI4IDguMDE5LDAgeiBtIC04Ljg3Miw3Mi4xNzQgaCA5LjY4MiB2IDguNTYxIGggLTkuNjgyIHogbSA5LjY3NiwtNS45MjkgSCA1Ni41OCBDIDU1LjYxNiw1Ni40NjEgNTMuNTk4LDQ5LjAwMSA1My41OTgsMzcuMjYyIGMgMCwtNC4zMzEgMy41MSwtNy44NDIgNy44NDEsLTcuODQyIDQuMzMyLDAgNy44NDIsMy41MTEgNy44NDIsNy44NDIgMTBlLTQsMTEuNzM0IC0yLjA0NSwxOS4yMDkgLTMuMDI3LDMwLjk1NSB6IiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGZpbGwtcnVsZT0iZXZlbm9kZCIgdHJhbnNmb3JtPSJtYXRyaXgoLjEzMDIgMCAwIC4xMzAyIDAgMS42NjUpIi8+PC9zdmc+');
   --icon-Widget: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTEuNSwxIEMxLjIyMzg1NzYzLDEgMSwxLjIyMzg1NzYzIDEsMS41IEwxLDUuNSBDMSw1Ljc3NjE0MjM3IDEuMjIzODU3NjMsNiAxLjUsNiBMNS41LDYgQzUuNzc2MTQyMzcsNiA2LDUuNzc2MTQyMzcgNiw1LjUgTDYsMS41IEM2LDEuMjIzODU3NjMgNS43NzYxNDIzNywxIDUuNSwxIEwxLjUsMSBaIE0xLjUsMCBMNS41LDAgQzYuMzI4NDI3MTIsLTEuNjY1MzM0NTRlLTE2IDcsMC42NzE1NzI4NzUgNywxLjUgTDcsNS41IEM3LDYuMzI4NDI3MTIgNi4zMjg0MjcxMiw3IDUuNSw3IEwxLjUsNyBDMC42NzE1NzI4NzUsNyAxLjY2NTMzNDU0ZS0xNiw2LjMyODQyNzEyIDAsNS41IEwwLDEuNSBDLTguMzI2NjcyNjhlLTE3LDAuNjcxNTcyODc1IDAuNjcxNTcyODc1LDEuNjY1MzM0NTRlLTE2IDEuNSwwIFogTTEyLjY5NDQxNTQsMS4xMTE4MDg5IEMxMi41ODcwMjEyLDEuMDA0NDE0NzIgMTIuNDEyOTAwOCwxLjAwNDQxNDcyIDEyLjMwNTUwNjcsMS4xMTE4MDg5IEwxMC4xMTIwNjE0LDMuMzA1MjU0MTMgQzEwLjAwNDY2NzMsMy40MTI2NDgzMSAxMC4wMDQ2NjczLDMuNTg2NzY4NjggMTAuMTEyMDYxNCwzLjY5NDE2Mjg2IEwxMi4zMDU1MDY3LDUuODg3NjA4MSBDMTIuNDEyOTAwOCw1Ljk5NTAwMjI4IDEyLjU4NzAyMTIsNS45OTUwMDIyOCAxMi42OTQ0MTU0LDUuODg3NjA4MSBMMTQuODg3ODYwNiwzLjY5NDE2Mjg2IEMxNC45OTUyNTQ4LDMuNTg2NzY4NjggMTQuOTk1MjU0OCwzLjQxMjY0ODMxIDE0Ljg4Nzg2MDYsMy4zMDUyNTQxMyBMMTIuNjk0NDE1NCwxLjExMTgwODkgWiBNMTMuNDAxNTIyMiwwLjQwNDcwMjExOCBMMTUuNTk0OTY3NCwyLjU5ODE0NzM1IEMxNi4wOTI4ODU5LDMuMDk2MDY1ODIgMTYuMDkyODg1OSwzLjkwMzM1MTE3IDE1LjU5NDk2NzQsNC40MDEyNjk2NCBMMTMuNDAxNTIyMiw2LjU5NDcxNDg4IEMxMi45MDM2MDM3LDcuMDkyNjMzMzUgMTIuMDk2MzE4NCw3LjA5MjYzMzM1IDExLjU5ODM5OTksNi41OTQ3MTQ4OCBMOS40MDQ5NTQ2NSw0LjQwMTI2OTY0IEM4LjkwNzAzNjE4LDMuOTAzMzUxMTcgOC45MDcwMzYxOCwzLjA5NjA2NTgyIDkuNDA0OTU0NjUsMi41OTgxNDczNSBMMTEuNTk4Mzk5OSwwLjQwNDcwMjExOCBDMTIuMDk2MzE4NCwtMC4wOTMyMTYzNTQzIDEyLjkwMzYwMzcsLTAuMDkzMjE2MzU0MyAxMy40MDE1MjIyLDAuNDA0NzAyMTE4IFogTTEuNSwxMCBDMS4yMjM4NTc2MywxMCAxLDEwLjIyMzg1NzYgMSwxMC41IEwxLDE0LjUgQzEsMTQuNzc2MTQyNCAxLjIyMzg1NzYzLDE1IDEuNSwxNSBMNS41LDE1IEM1Ljc3NjE0MjM3LDE1IDYsMTQuNzc2MTQyNCA2LDE0LjUgTDYsMTAuNSBDNiwxMC4yMjM4NTc2IDUuNzc2MTQyMzcsMTAgNS41LDEwIEwxLjUsMTAgWiBNMS41LDkgTDUuNSw5IEM2LjMyODQyNzEyLDkgNyw5LjY3MTU3Mjg4IDcsMTAuNSBMNywxNC41IEM3LDE1LjMyODQyNzEgNi4zMjg0MjcxMiwxNiA1LjUsMTYgTDEuNSwxNiBDMC42NzE1NzI4NzUsMTYgMS42NjUzMzQ1NGUtMTYsMTUuMzI4NDI3MSAwLDE0LjUgTDAsMTAuNSBDLTguMzI2NjcyNjhlLTE3LDkuNjcxNTcyODggMC42NzE1NzI4NzUsOSAxLjUsOSBaIE0xMC41LDEwIEMxMC4yMjM4NTc2LDEwIDEwLDEwLjIyMzg1NzYgMTAsMTAuNSBMMTAsMTQuNSBDMTAsMTQuNzc2MTQyNCAxMC4yMjM4NTc2LDE1IDEwLjUsMTUgTDE0LjUsMTUgQzE0Ljc3NjE0MjQsMTUgMTUsMTQuNzc2MTQyNCAxNSwxNC41IEwxNSwxMC41IEMxNSwxMC4yMjM4NTc2IDE0Ljc3NjE0MjQsMTAgMTQuNSwxMCBMMTAuNSwxMCBaIE0xMC41LDkgTDE0LjUsOSBDMTUuMzI4NDI3MSw5IDE2LDkuNjcxNTcyODggMTYsMTAuNSBMMTYsMTQuNSBDMTYsMTUuMzI4NDI3MSAxNS4zMjg0MjcxLDE2IDE0LjUsMTYgTDEwLjUsMTYgQzkuNjcxNTcyODgsMTYgOSwxNS4zMjg0MjcxIDksMTQuNSBMOSwxMC41IEM5LDkuNjcxNTcyODggOS42NzE1NzI4OCw5IDEwLjUsOSBaIiBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9Im5vbnplcm8iLz48L3N2Zz4=');
   --icon-Wrap: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTksMTIgTDksMTMgTDguMzA3ODIzNjYsMTEuOTYxNzM1NSBDOC4xMjcwNjQ4MywxMS44ODY0MTQ4IDgsMTEuNzA4MDQ2MiA4LDExLjUgQzgsMTEuMjkxOTUzOCA4LjEyNzA2NDgzLDExLjExMzU4NTIgOC4zMDc4MjI3NCwxMS4wMzgyNjU5IEw5LDEwIEw5LDExIEwxMS4zMjYwODcsMTEgQzEyLjI0NjY0MTUsMTEgMTMsMTAuMjIwMjczOSAxMyw5LjI1IEMxMyw4LjI3OTcyNjA1IDEyLjI0NjY0MTUsNy41IDExLjMyNjA4Nyw3LjUgTDIuNSw3LjUgQzIuMjIzODU3NjMsNy41IDIsNy4yNzYxNDIzNyAyLDcgQzIsNi43MjM4NTc2MyAyLjIyMzg1NzYzLDYuNSAyLjUsNi41IEwxMS4zMjYwODcsNi41IEMxMi44MDY3NzA1LDYuNSAxNCw3LjczNDk5MjU3IDE0LDkuMjUgQzE0LDEwLjc2NTAwNzQgMTIuODA2NzcwNSwxMiAxMS4zMjYwODcsMTIgTDksMTIgWiBNMi41LDIgTDEzLjUsMiBDMTMuNzc2MTQyNCwyIDE0LDIuMjIzODU3NjMgMTQsMi41IEMxNCwyLjc3NjE0MjM3IDEzLjc3NjE0MjQsMyAxMy41LDMgTDIuNSwzIEMyLjIyMzg1NzYzLDMgMiwyLjc3NjE0MjM3IDIsMi41IEMyLDIuMjIzODU3NjMgMi4yMjM4NTc2MywyIDIuNSwyIFogTTIuNSwxMSBMNS41LDExIEM1Ljc3NjE0MjM3LDExIDYsMTEuMjIzODU3NiA2LDExLjUgQzYsMTEuNzc2MTQyNCA1Ljc3NjE0MjM3LDEyIDUuNSwxMiBMMi41LDEyIEMyLjIyMzg1NzYzLDEyIDIsMTEuNzc2MTQyNCAyLDExLjUgQzIsMTEuMjIzODU3NiAyLjIyMzg1NzYzLDExIDIuNSwxMSBaIiBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9Im5vbnplcm8iLz48L3N2Zz4=');
diff --git a/static/img/get-started.png b/static/img/get-started.png
new file mode 100644
index 0000000000000000000000000000000000000000..1462879af8c2992a7396ab2f7f362f4667a927c1
GIT binary patch
literal 57667
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
F{|Dj(SSbJi

literal 0
HcmV?d00001

diff --git a/static/img/tutorial-screenshot.png b/static/img/tutorial-screenshot.png
new file mode 100644
index 0000000000000000000000000000000000000000..57be7591ca84ab661b36337ca64a4b532d07d49b
GIT binary patch
literal 184287
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%0DqK;HMx<4lNy0SJay@A+f)5Mf
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%0A$uVS6!61H5=ZYOTVH+fNEk2d;gMr
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
DGp())

literal 0
HcmV?d00001

diff --git a/static/img/youtube-screenshot.png b/static/img/youtube-screenshot.png
new file mode 100644
index 0000000000000000000000000000000000000000..fa67828132a65a779697a32e255410dc01924ead
GIT binary patch
literal 278339
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%0AroZm(LI_QF3-?68Sm~xL
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

literal 0
HcmV?d00001

diff --git a/static/ui-icons/Login/LoginStreamline.svg b/static/ui-icons/Login/LoginStreamline.svg
index 6a7c849a..15e5a0d9 100644
--- a/static/ui-icons/Login/LoginStreamline.svg
+++ b/static/ui-icons/Login/LoginStreamline.svg
@@ -1,7 +1,12 @@
 <svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
-<g opacity="0.1">
-<path fill-rule="evenodd" clip-rule="evenodd" d="M39.5402 0H18.0047C8.06752 0 0 8.9774 0 20.0353V40H21.7051C31.5487 40 39.5402 31.1068 39.5402 20.1531V0Z" fill="#F9AE41"/>
+<g clip-path="url(#clip0_1349_6885)">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M39.5402 0H18.0047C8.06752 0 0 8.9774 0 20.0353V40H21.7051C31.5487 40 39.5402 31.1068 39.5402 20.1531V0Z" fill="white"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M23.4917 27.9306L29.453 18.8307C29.652 18.5268 29.5776 18.1119 29.2866 17.904C29.1805 17.8281 29.0548 17.7875 28.9262 17.7875H24.279V11.9948C24.279 11.6266 23.9932 11.3281 23.6407 11.3281C23.43 11.3281 23.2329 11.4367 23.1139 11.6183L17.1526 20.7183C16.9535 21.0221 17.028 21.437 17.3189 21.6449C17.4251 21.7208 17.5507 21.7614 17.6794 21.7614H22.3266V27.5542C22.3266 27.9223 22.6123 28.2208 22.9649 28.2208C23.1756 28.2208 23.3727 28.1122 23.4917 27.9306Z" fill="#F9AE41"/>
+<path opacity="0.44" fill-rule="evenodd" clip-rule="evenodd" d="M17.1458 24.7419C17.8508 24.7419 18.4224 25.3388 18.4224 26.0752V26.3889C18.4224 27.1253 17.8508 27.7223 17.1458 27.7223H11.8892C11.1841 27.7223 10.6126 27.1253 10.6126 26.3889V26.0752C10.6126 25.3388 11.1841 24.7419 11.8892 24.7419H17.1458ZM14.2171 18.7811C14.9221 18.7811 15.4937 19.3781 15.4937 20.1144V20.4282C15.4937 21.1645 14.9221 21.7615 14.2171 21.7615H9.93675C9.23171 21.7615 8.66016 21.1645 8.66016 20.4282V20.1144C8.66016 19.3781 9.23171 18.7811 9.93675 18.7811H14.2171ZM17.1458 12.8203C17.8508 12.8203 18.4224 13.4173 18.4224 14.1536V14.4674C18.4224 15.2038 17.8508 15.8007 17.1458 15.8007H11.8892C11.1841 15.8007 10.6126 15.2038 10.6126 14.4674V14.1536C10.6126 13.4173 11.1841 12.8203 11.8892 12.8203H17.1458Z" fill="#F9AE41"/>
 </g>
-<path fill-rule="evenodd" clip-rule="evenodd" d="M22.4917 27.9306L28.453 18.8307C28.652 18.5268 28.5776 18.1119 28.2866 17.904C28.1805 17.8281 28.0548 17.7875 27.9262 17.7875H23.279V11.9948C23.279 11.6266 22.9932 11.3281 22.6407 11.3281C22.43 11.3281 22.2329 11.4367 22.1139 11.6183L16.1526 20.7183C15.9535 21.0221 16.028 21.437 16.3189 21.6449C16.4251 21.7208 16.5507 21.7614 16.6794 21.7614H21.3266V27.5542C21.3266 27.9223 21.6123 28.2208 21.9649 28.2208C22.1756 28.2208 22.3727 28.1122 22.4917 27.9306Z" fill="#F9AE41"/>
-<path opacity="0.44" fill-rule="evenodd" clip-rule="evenodd" d="M16.1453 24.7419C16.8503 24.7419 17.4219 25.3388 17.4219 26.0752V26.3889C17.4219 27.1253 16.8503 27.7223 16.1453 27.7223H10.8887C10.1837 27.7223 9.61211 27.1253 9.61211 26.3889V26.0752C9.61211 25.3388 10.1837 24.7419 10.8887 24.7419H16.1453ZM13.2166 18.7811C13.9217 18.7811 14.4932 19.3781 14.4932 20.1144V20.4282C14.4932 21.1645 13.9217 21.7615 13.2166 21.7615H8.93626C8.23122 21.7615 7.65967 21.1645 7.65967 20.4282V20.1144C7.65967 19.3781 8.23122 18.7811 8.93626 18.7811H13.2166ZM16.1453 12.8203C16.8503 12.8203 17.4219 13.4173 17.4219 14.1536V14.4674C17.4219 15.2038 16.8503 15.8007 16.1453 15.8007H10.8887C10.1837 15.8007 9.61211 15.2038 9.61211 14.4674V14.1536C9.61211 13.4173 10.1837 12.8203 10.8887 12.8203H16.1453Z" fill="#F9AE41"/>
+<defs>
+<clipPath id="clip0_1349_6885">
+<rect width="40" height="40" fill="white"/>
+</clipPath>
+</defs>
 </svg>
diff --git a/static/ui-icons/Login/LoginUnify.svg b/static/ui-icons/Login/LoginUnify.svg
index 48d7d260..ab3f37e6 100644
--- a/static/ui-icons/Login/LoginUnify.svg
+++ b/static/ui-icons/Login/LoginUnify.svg
@@ -1,5 +1,12 @@
 <svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path opacity="0.1" fill-rule="evenodd" clip-rule="evenodd" d="M39.5402 0H18.0047C8.06752 0 0 8.9774 0 20.0353V40H21.7051C31.5487 40 39.5402 31.1068 39.5402 20.1531V0Z" fill="#7141F9"/>
-<path opacity="0.44" d="M13.8297 16.7786V23.223C13.8297 25.0026 15.211 26.4452 16.9148 26.4452H23.0851V27.324C23.0851 28.8431 22.2958 29.6675 20.8413 29.6675H12.9883C11.5339 29.6675 10.7446 28.8431 10.7446 27.324V19.122C10.7446 17.6029 11.5339 16.7786 12.9883 16.7786H13.8297ZM25.9832 11.4082C27.4376 11.4082 28.2269 12.2326 28.2269 13.7516V21.9537C28.2269 23.4727 27.4376 24.2971 25.9832 24.2971H25.1418V17.8526C25.1418 16.0731 23.7605 14.6304 22.0567 14.6304H15.8865V13.7516C15.8865 12.2326 16.6758 11.4082 18.1302 11.4082H25.9832Z" fill="#7141F9"/>
-<path fill-rule="evenodd" clip-rule="evenodd" d="M17.1956 16.7793H21.7765C22.6249 16.7793 23.0853 17.2602 23.0853 18.1463V22.9308C23.0853 23.8169 22.6249 24.2978 21.7765 24.2978H17.1956C16.3471 24.2978 15.8867 23.8169 15.8867 22.9308V18.1463C15.8867 17.2602 16.3471 16.7793 17.1956 16.7793Z" fill="#7141F9"/>
+<g clip-path="url(#clip0_1349_6871)">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M39.5402 0H18.0047C8.06752 0 0 8.9774 0 20.0353V40H21.7051C31.5487 40 39.5402 31.1068 39.5402 20.1531V0Z" fill="white"/>
+<path opacity="0.44" d="M13.8312 16.7786V23.223C13.8312 25.0026 15.2125 26.4452 16.9163 26.4452H23.0865V27.324C23.0865 28.8431 22.2972 29.6675 20.8428 29.6675H12.9898C11.5354 29.6675 10.7461 28.8431 10.7461 27.324V19.122C10.7461 17.6029 11.5354 16.7786 12.9898 16.7786H13.8312ZM25.9847 11.4082C27.4391 11.4082 28.2284 12.2326 28.2284 13.7516V21.9537C28.2284 23.4727 27.4391 24.2971 25.9847 24.2971H25.1433V17.8526C25.1433 16.0731 23.762 14.6304 22.0582 14.6304H15.8879V13.7516C15.8879 12.2326 16.6772 11.4082 18.1317 11.4082H25.9847Z" fill="#7141F9"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M17.1975 16.7793H21.7784C22.6268 16.7793 23.0873 17.2602 23.0873 18.1463V22.9308C23.0873 23.8169 22.6268 24.2978 21.7784 24.2978H17.1975C16.3491 24.2978 15.8887 23.8169 15.8887 22.9308V18.1463C15.8887 17.2602 16.3491 16.7793 17.1975 16.7793Z" fill="#7141F9"/>
+</g>
+<defs>
+<clipPath id="clip0_1349_6871">
+<rect width="40" height="40" fill="white"/>
+</clipPath>
+</defs>
 </svg>
diff --git a/static/ui-icons/Login/LoginVisualize.svg b/static/ui-icons/Login/LoginVisualize.svg
index f9884240..e15dea58 100644
--- a/static/ui-icons/Login/LoginVisualize.svg
+++ b/static/ui-icons/Login/LoginVisualize.svg
@@ -1,7 +1,14 @@
 <svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path opacity="0.1" fill-rule="evenodd" clip-rule="evenodd" d="M39.5402 0H18.0047C8.06752 0 0 8.9774 0 20.0353V40H21.7051C31.5487 40 39.5402 31.1068 39.5402 20.1531V0Z" fill="#16B378"/>
-<path opacity="0.44" d="M23.9718 12.2216C23.9718 11.3625 23.305 10.666 22.4824 10.666C21.6599 10.666 20.993 11.3625 20.993 12.2216V26.7401C20.993 27.5992 21.6599 28.2956 22.4824 28.2956C23.305 28.2956 23.9718 27.5992 23.9718 26.7401V12.2216Z" fill="#16B378"/>
-<path d="M19.007 17.7304C19.007 16.8713 18.3402 16.1748 17.5177 16.1748C16.6951 16.1748 16.0283 16.8713 16.0283 17.7304V26.7396C16.0283 27.5987 16.6951 28.2952 17.5177 28.2952C18.3402 28.2952 19.007 27.5987 19.007 26.7396V17.7304Z" fill="#16B378"/>
-<path d="M28.9362 19.9345C28.9362 19.0754 28.2694 18.3789 27.4469 18.3789C26.6243 18.3789 25.9575 19.0754 25.9575 19.9345V26.74C25.9575 27.5991 26.6243 28.2956 27.4469 28.2956C28.2694 28.2956 28.9362 27.5991 28.9362 26.74V19.9345Z" fill="#16B378"/>
-<path d="M14.0427 22.1386C14.0427 21.2795 13.3759 20.583 12.5533 20.583C11.7308 20.583 11.064 21.2795 11.064 22.1386V26.7404C11.064 27.5995 11.7308 28.296 12.5533 28.296C13.3759 28.296 14.0427 27.5995 14.0427 26.7404V22.1386Z" fill="#16B378"/>
+<g clip-path="url(#clip0_1349_6898)">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M39.5402 0H18.0047C8.06752 0 0 8.9774 0 20.0353V40H21.7051C31.5487 40 39.5402 31.1068 39.5402 20.1531V0Z" fill="white"/>
+<path opacity="0.44" d="M23.9729 12.9996C23.9729 12.2264 23.3061 11.5996 22.4835 11.5996C21.661 11.5996 20.9941 12.2264 20.9941 12.9996V26.0663C20.9941 26.8395 21.661 27.4663 22.4835 27.4663C23.3061 27.4663 23.9729 26.8395 23.9729 26.0663V12.9996Z" fill="#16B378"/>
+<path d="M19.008 17.9576C19.008 17.1844 18.3412 16.5576 17.5187 16.5576C16.6961 16.5576 16.0293 17.1844 16.0293 17.9576V26.066C16.0293 26.8391 16.6961 27.466 17.5187 27.466C18.3412 27.466 19.008 26.8391 19.008 26.066V17.9576Z" fill="#16B378"/>
+<path d="M28.9358 19.941C28.9358 19.1678 28.2689 18.541 27.4464 18.541C26.6238 18.541 25.957 19.1678 25.957 19.941V26.066C25.957 26.8392 26.6238 27.466 27.4464 27.466C28.2689 27.466 28.9358 26.8392 28.9358 26.066V19.941Z" fill="#16B378"/>
+<path d="M14.0432 21.9254C14.0432 21.1522 13.3764 20.5254 12.5538 20.5254C11.7313 20.5254 11.0645 21.1522 11.0645 21.9254V26.0671C11.0645 26.8403 11.7313 27.4671 12.5538 27.4671C13.3764 27.4671 14.0432 26.8403 14.0432 26.0671V21.9254Z" fill="#16B378"/>
+</g>
+<defs>
+<clipPath id="clip0_1349_6898">
+<rect width="40" height="40" fill="white"/>
+</clipPath>
+</defs>
 </svg>
diff --git a/static/ui-icons/UI/Skip.svg b/static/ui-icons/UI/Skip.svg
new file mode 100644
index 00000000..c9e55c49
--- /dev/null
+++ b/static/ui-icons/UI/Skip.svg
@@ -0,0 +1,3 @@
+<svg width="21" height="21" viewBox="0 0 21 21" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M17.0625 3.82104H15.8125V16.321H17.0625V3.82104ZM14.2643 10.9648C14.5785 10.7766 14.7708 10.4373 14.7708 10.071C14.7708 9.7048 14.5785 9.36546 14.2643 9.17732L5.82677 4.12551C5.50493 3.93281 5.10435 3.92804 4.77802 4.11301C4.45168 4.29798 4.25 4.64412 4.25 5.01923V15.1229C4.25 15.498 4.45168 15.8441 4.77802 16.0291C5.10435 16.214 5.50493 16.2093 5.82677 16.0166L14.2643 10.9648ZM5.29167 5.01923L13.7292 10.071L5.29167 15.1229L5.29167 5.01923Z" fill="#16B378"/>
+</svg>
diff --git a/static/ui-icons/UI/Star.svg b/static/ui-icons/UI/Star.svg
new file mode 100644
index 00000000..1c8a5400
--- /dev/null
+++ b/static/ui-icons/UI/Star.svg
@@ -0,0 +1,3 @@
+<svg width="24" height="25" viewBox="0 0 24 25" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M2.24081 4.84923C2.60023 4.4299 3.23153 4.38134 3.65086 4.74076L7.15086 7.74076C7.57018 8.10018 7.61875 8.73148 7.25932 9.15081C6.8999 9.57014 6.2686 9.6187 5.84928 9.25927L2.34928 6.25927C1.92995 5.89985 1.88139 5.26855 2.24081 4.84923ZM11.5 7.02081C11.137 7.02081 10.8052 7.2259 10.6429 7.55057L8.87455 11.0872L4.98709 11.6527C4.62616 11.7052 4.32631 11.958 4.2136 12.3049C4.1009 12.6518 4.19487 13.0326 4.45602 13.2872L7.28619 16.0466L6.64959 19.9369C6.59079 20.2962 6.74071 20.6578 7.03651 20.8702C7.33231 21.0825 7.72289 21.1089 8.04453 20.9382L11.5 19.1047L14.9555 20.9382C15.2772 21.1089 15.6678 21.0825 15.9636 20.8702C16.2594 20.6578 16.4093 20.2962 16.3505 19.9369L15.7139 16.0466L18.544 13.2872C18.8052 13.0326 18.8992 12.6518 18.7865 12.3049C18.6738 11.958 18.3739 11.7052 18.013 11.6527L14.1255 11.0872L12.3572 7.55057C12.1949 7.2259 11.863 7.02081 11.5 7.02081ZM10.365 12.3921L11.5 10.122L12.6351 12.3921C12.7753 12.6726 13.0439 12.8667 13.3543 12.9119L15.8162 13.27L14.0185 15.0227C13.7946 15.241 13.6913 15.5549 13.7418 15.8636L14.1469 18.3393L11.9492 17.1732C11.6683 17.0242 11.3318 17.0242 11.0508 17.1732L8.85317 18.3393L9.25829 15.8636C9.3088 15.5549 9.2055 15.241 8.98155 15.0227L7.18391 13.27L9.64579 12.9119C9.95613 12.8667 10.2248 12.6726 10.365 12.3921ZM19.3492 4.74076C19.7686 4.38134 20.3999 4.4299 20.7593 4.84923C21.1187 5.26855 21.0701 5.89985 20.6508 6.25927L17.1508 9.25927C16.7315 9.6187 16.1002 9.57014 15.7408 9.15081C15.3814 8.73148 15.4299 8.10018 15.8492 7.74076L19.3492 4.74076ZM22.2753 19.6316C21.9265 20.0598 21.2966 20.1241 20.8684 19.7753L18.3684 17.7386C17.9403 17.3897 17.876 16.7598 18.2248 16.3317C18.5736 15.9035 19.2035 15.8392 19.6317 16.188L22.1317 18.2247C22.5599 18.5736 22.6242 19.2035 22.2753 19.6316ZM2.13165 19.7753C1.70347 20.1241 1.07358 20.0598 0.724752 19.6316C0.375919 19.2035 0.440238 18.5736 0.868413 18.2247L3.36841 16.188C3.79659 15.8392 4.42648 15.9035 4.77531 16.3317C5.12414 16.7598 5.05982 17.3897 4.63165 17.7386L2.13165 19.7753Z" fill="#16B378"/>
+</svg>
diff --git a/static/ui-icons/UI/VideoPlay.svg b/static/ui-icons/UI/VideoPlay.svg
new file mode 100644
index 00000000..f94d390a
--- /dev/null
+++ b/static/ui-icons/UI/VideoPlay.svg
@@ -0,0 +1,6 @@
+<svg width="39" height="35" viewBox="0 0 39 35" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g id="Group">
+<path id="Vector (Stroke)" fill-rule="evenodd" clip-rule="evenodd" d="M4.0625 3.25C3.40666 3.25 2.875 3.78166 2.875 4.4375V30.5625C2.875 31.2183 3.40666 31.75 4.0625 31.75H34.9375C35.5933 31.75 36.125 31.2183 36.125 30.5625V4.4375C36.125 3.78166 35.5933 3.25 34.9375 3.25H4.0625ZM0.5 4.4375C0.5 2.46999 2.09499 0.875 4.0625 0.875H34.9375C36.905 0.875 38.5 2.46999 38.5 4.4375V30.5625C38.5 32.53 36.905 34.125 34.9375 34.125H4.0625C2.09499 34.125 0.5 32.53 0.5 30.5625V4.4375Z" fill="white"/>
+<path id="Vector (Stroke)_2" fill-rule="evenodd" clip-rule="evenodd" d="M12.9911 8.14653C13.371 7.93797 13.8344 7.95296 14.2 8.18565L27.2625 16.4982C27.6051 16.7161 27.8125 17.094 27.8125 17.5C27.8125 17.906 27.6051 18.2839 27.2625 18.5019L14.2 26.8144C13.8344 27.047 13.371 27.062 12.9911 26.8535C12.6111 26.6449 12.375 26.2459 12.375 25.8125V9.1875C12.375 8.75409 12.6111 8.35509 12.9911 8.14653ZM14.75 11.3507V23.6493L24.4131 17.5L14.75 11.3507Z" fill="white"/>
+</g>
+</svg>
diff --git a/static/ui-icons/UI/VideoPlay2.svg b/static/ui-icons/UI/VideoPlay2.svg
new file mode 100644
index 00000000..11c593e7
--- /dev/null
+++ b/static/ui-icons/UI/VideoPlay2.svg
@@ -0,0 +1,3 @@
+<svg width="25" height="25" viewBox="0 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M6.5 7.24105C6.5 5.69747 8.17443 4.73573 9.50774 5.51349L18.5231 10.7725C19.8461 11.5442 19.8461 13.4558 18.5231 14.2276L9.50774 19.4865C8.17443 20.2643 6.5 19.3026 6.5 17.759V7.24105ZM17.5154 12.5L8.5 7.24105V17.759L17.5154 12.5Z" fill="white"/>
+</svg>
diff --git a/test/fixtures/docs/GristNewUserInfo.grist b/test/fixtures/docs/GristNewUserInfo.grist
index 1594cde848c93d86954eee8c423ec2176a1e593e..546865db53a107bd1cf405dc7af1cf81682db57b 100644
GIT binary patch
delta 7183
zcmbtZdu$xlxu0`(_C1faz3Yt=g4fTr6DQ2>?99%Dm~0*natTgik`_p!FgtVBF<I|M
z_8OAwB<tPamVkh`+h$tSasyXf;7X-xT(NCYLFr3EqL5Z?DW$ZfNVKXdL{-7%3RK-Q
zGhS!>0Icr*F+Sfp-}&Y{-|u_P@$|HR`Vw`a6~tyEt-v!o+qw}Dn^nR|5CmjDz)zxs
z{fPa*x5od5=V9*;-Osy_^B_3|M~DyE4`*j{bB945R3C)kU09t0AOPl&>nmQ^7@qrx
z^`?R@_ANt3%#gwUgZ(@E7xt~WHTzy-@4&NM$ynQT8bA6@;m5{%@MFU`e)Q&i_}Sv^
zCfFZ%yV(M}nfWX81asIQ^xf~<;d_N?_I^ZPr5~VoQU9X8=N+TQsXp&_{qOiM`R|--
z2>jhs+)b8f$SHy(b0{eZNj1(VGeSlY(-|qF$%2v);t5{C(?UiPkebvqNlI%<GM<n!
z2|-h|7{?RmOfhL*NhB1!CP|67qGd!y<I_S?izj7K)ObzgIE46wqNrSq6XJ2iYeoE*
zLxL=8Jdz}$Y|2puUX~MlEXngojB{xvo`{KJCdrAJjF=SDoUBNKD2cor=VGEP2wYND
zl}r*p<+#LiM9CB*Yci6O_+XltMu?L|P32KME(!@=R3%AN`Gj0dAYPS4BxX2-^-wi|
zLvltC64(}qm>$5^0iuc!5;;*z#M4PdjK`1~*O1I>3gR=oBFbtamWgu-NlhRLs~bzC
z#ke4&1WF=7kYhzlipa@{jG%CFE)y5Uv=q<CF{v2W1R<?q#T8zO%VI|0Bss%ryejcY
z6cbgILlURrW#OzPCKHpAs)8~ZC8?^|!m7-pge-_0FDEcocv?<s9EzuTSxIwBJgp@0
zkpvYVO^Wf-8A}SMa7e*6=$G(eBvliI7{X>&I4P}i3ZLNcQ;8)rNfp~xku;365a-j$
zB*G4mOmnAAG5V$_V9IsM8b5pikMFU@FCT*`aH4YO@mngf>~8(7uVS)LIh=c~;uzaq
z;q$vIkBr@{|LA|<HgIp{>ewxnhmNGc@yaF4|6+az&Av)F*ee8kg?*R(9rn{Xb{hpq
zq6JPIQ^t`-oWoA<?*iGps$?4-B+&)U;nB(51j-eNa{)*aZS!;1%;o7fw}B0nKfKuE
z`ZdA+n*AG=`Xalvz18dSfMGOoq6v5ilQ5|~7BoS+f)Gyv%mMaKqKU#No`w!5?LSR4
z`){kbuXelN_rL7F&HrCmTC2YpZEhsIV10=8((66Ok09tkethI;P8mZ7(8)q=jMa}~
z#wI&sgRwZC&!X+QQ}e@Uc)7d<wwgR+OEeU~FRuLM_Z@SOy%7R)zBm83y^H;jpuukm
zHq3sXexBN}I5UCYI^0HPsx$<CNwOb;eB~~1w+{~<4@aZm+>MoI$YZ{8ALTa4`@)!f
z)!i3qA6M@Ey#6%-cKFAUnjhD8D&v|t5ef9(cMI(f3j(-bE-2|Nsx&x))n_4i54ent
zli7;`T(e$76YBWr3B#1_kply_ABgN4*uDMWo&%9+wAV_ERpbKS@b;3wAbg-qFteVB
z`!?5{tHpWF`vWox$~4QIr-%Hn_<MXk-f8biy^Vu6V^b4^w;zmDKWGPD;G1c0HkCO)
zqJP{D)>hASf^MjD2}nXqlZ~II30Bzjw7sUH6MCBfoAf8U!84?{pSWBdj)E!#P5Nvn
z2<fdGz*(}qmUUMj-vC+w>3e`UgN>Q=xLRF?QiNXW16zEhTZ8U!R~INB(Ql7}r#!V+
z=r0Jc+24z+$c$mU^U+VxH~MM%_LhrbcX<1DFypP_;~oaKlZL7nHiJUjQ)CLH0xmX1
zuy3<JVgHkzVvn#X?6-Yvah)qjcs(sN@Y3cFdoE4T6QjA2EYhxX1_`ii5zeo+lh{Q)
zEv@#gmTMS6WrmT~;b=4@EaHI{lNnY{Bd#?4Cy#)XV=L95yDxyQ1I(|OpEA!emzY^*
zib2fhnG_Ra+L<PXq~E9Cp<kn~(3j~a=r7Y{`Z#@v-bHVs*V8WQ1L_^>H`LFl=cxat
zE>cA*PaUQ9QYng~B2)uD?X15@_xo|M01s`PG8AUE`Uy}YUBNJOqm3q;!t@p!dt);r
z+m>Ao&5UHD$v`ucw9zg)6cRD*o`0Jw6(x$MQv?~o&gZ9?gq`;J8QxBNeN5a=d%R4{
zPP;t}XQy3m<_0_MbcHr!+R&qD9NU#@G}R!Qm`!#*k_`3D^I2?^#pVocTr_QT9r)MC
zY_RWkI6}RP*hq`5f$6!%RwP^yGSO=jgp&<0-FBK`=`P;_C?p*Uc4Bhgh$Ob5`3uq=
zHYPtsx7$dcpKh~}ULPHqCyn!y9xvT$TXuWs>usdVO|Q3+PFL_cOfI~?W;%MEjfo_K
zYwb)m!v$OHv)FE5FqChk*Vy(t9Ko=it7bO3*~SDR9lAz3*Z>{0kqldX`!Uc~YG;WV
z$nIj!latQ(sHgo;`w8EE2kE|_nDPA8a~D12otd2-z5zZQ#u?{)xVIOazxlq??e1`E
zD;}ANdm(Ta2HjPLhn;XI&RMkzwk&ziuNl>7*;;Qo7jcI-ZUi$;I^PdFK&O>Dv;FX!
zMZ9qgjpdhZ!t+@rjj}5(pqx=*wD!o3y?gfzY#&-EA{^dzuToHtA#G<qJB%`WMkfkO
zBn%zgv&YcZ%I)ixtl~0MKDpK%j>W)Cwdp1p1Gix2!sr;<g|et%ymZ(00|N&JZyi{8
z>%7FwXci5eT>3gJ@LXt}b$q4ze*&BYMkO`QY}OU;!8W&nLmBD)0&D|amTd|G{13x6
zCq{F*6;Ik*a(24I12Ir)8_j9x<k69FeAc7e3k5|zHip}viKB)>gBl(#U+dX9Jb;f8
z864U*aC>C=y1BR4+#K0Ev;srVg7Dtb+75j@iCej-emM#6+)x_qHrs~se$$`kh2Lhh
zZWD`$8_c7+td+_Y=XG~vM{~yqv5A*-2G`(PY3UQ)#8xs*Q&-n)fgi)3ve;#o;`?r}
z>$ekSvo-b1@n%yy(irQ1>cNM5v>yib=LLA3b3A|Vpr&8Jm75;V=P}~*eyG3M3Bvl1
z1sEg?r%oV~V~d*46)*-XX+AlD#<7zwTf*%?NosLdi)~;6JjVetGDikJZoM2K15c6r
zVB{(C0*v?^phWoIa{U;TaABJOw?44#w1>doE1+_ZSnKcq&GvrkwEmB~U}#^xawfev
z*(s{g?hQ?jjh?Pt-51gSg+CM;CuzJgh}-P(LPX7HC&zO7{tVpJdEvE--|(N>F=^yL
z!#tBscrb!+-a)AmR4|=f-+MP~)w`l_J7u(36S%KN+99&iIy|PopN26#@NG!ym%at5
z%Fl-n>OD_FR)76bc(_x-+*6ar9{fsd_|axOoHTmJ6WBR(g+XnU)KkyGX8qkXe#09o
zY~3w?_8vo^c~VQtzWl^!1D@inL*%slAuLscM=ZS_*PsX_{V@&7K-L?+4dqJUNPrpQ
zxck>lSpYt=xpMS)s>(#+KRt6G;)qZr@j5~M&byNY#Ot%OFYWzeXNfR*;e$7=<~#S(
zK68u2w1sxPf|j5?T|*mzV&&=`t(}+tsILy&RK1_vm32qgRW=P%l~)gShx(Tyh-)9A
z$DW04fzV=@fQ3rGfl3$N+toO<$X#k1Bl|E~4{R$ggj=0A1ZRu$;Z~=dB>-Tne^$70
zZ>$oMTg4UcFIInjVTRqZPzB-23&R>5ZO~tS6pn7WaV4mh1Sn=%5^P3_utn_F&la*m
z#0K%MWf6<Bxf!Q7T7R}Kgsb5IUkaYJ9N?iyy~a6ncHRN%HS*$od$#5PGo2^)uM+5#
zSmzCs-gXfVP)1U%HH&)eBCec|>9G4+X6~>vOd8jyW*IlJUf8$q8Q7v{b8y`vag{4y
z5U&Z+M|F5;fpjU$#c<pgJI+{0J$O^Sr#pRS9;rI5v?Nr|)Q~~|7fGBe-~00_>tL$h
z8oel2Zdn>TMuoI;rSaKg3y}F-x#g2`Wo=`m0f&lF>>bWNg35Saa`xeN<$CA7FSV2$
z1}YFf)Lr)lbeTA3;Zm=pCAgfc;gV{ZG&01_V@hrWUyRK6ED^KKGaHu^KYetS#qHm?
zCSP<*ZUk7T{z8u}ER4@CefAj`(tW+StqAMi8G)apYawQ=PU)}o!X0?!PsiaQiy^$9
z!x-QzP78y2)gnqn*}|Y+NlP#&uNnjH^{=lIgL?nPumT3gCGRy1tc>*C5qR?g3<iS*
zgSGhfad`UzTM&cMT*8;oCK!==^&_T<k_AS+l9qr`s(~@n$(6shN^95KSS~Iprn4`U
w6y9jcrpyY0oBBa*p@JA}D-;%U_wvGGOdEv-hsK>cy!-lVQWqB#gOB6>AAUP8F#rGn

delta 1336
zcmY*XZA@Eb6n@Xk?ft%e%LZ%5vfT?UuuGw%S+?jODL6p0W{wFi5@LhWFtV;CZBe0d
zwro@8$Dp~H*FPZHH2dLV)F!)g#E)e}Ba#f4EK><4jKr8oTy${~Hy7WwW*_&*$vMwC
z?|IHW&q+_q>9jPVLVZ?Kfy-u9FCbsjk2Zu5Cby_X9&(%95?#_Fe?s`2n_<JYVdg#j
z9rTbjqttQbleHKKM>-VFxnl>smK(%#V2m(}VrD?%oVpH26eDC?&h@b;1-#ysyG?|E
zgCVQ=&hC6?@&{S9%F_4;@)(7@!_?HhNKI{=nwqFcZK>3a$OUPdB$Wj@uDn5f@?HC{
z_EYvAWtUtpb=x<{=cQkArH)3)sE_P6SLbj5MvU%*yUj1o;Q;$`E0a;cym*1aFB#JZ
zcN<p^wVBHw&@gHAM1$tX6S#?u7pvyqrSPC}H*(EL?+rlI@Wq10SFvV=4pKrcBXW~m
zCzmPNMdB|lVNf+hVlVVYLPOC+IGRim%%IBup#mTQgEm6qKyN(Um-;ll)C3!ipTFG7
zE+KM*EYUvSlR5`xkqSKDF%(WD2V&6#CO{E^=l6vZgES5&6gh{=q$z`2sIg7S%Q8z}
zc1kXJ5gG(et<0#rqVn_;P@tAhn)_?99DLOA381c5gkq9M%1|O4iX{((<IU0Gdp@n(
zCUD05qMj3%{;sOE4I>3oh&ah-%8Xq1p!krY;=c(!sEMp)o1F|<!*5w07i<JGy9Lx-
zm(2mWe*8NY$r@U{e*jh=3zNcW&>Xrri6p!e*|SPgSP&ZdcX-6@Vc%z-#ed?R%4J1I
zDLEruk>;}5pgHIO8zWA_jCt0J?@$lTSa17q0+`W0Cua>iKmg-}!kSfE;Au%e`oxC9
zA&zY~Uv)r*^-%@XV6&zSK4dc6$~fz48CV$B85vu-dCCcg8G7c|iu1chBlg0@9$TO6
zeLfNnu^LMkgOJrxhxbzzX{C9#9=BVX3pRsAe>YKihS>{^JSMY<tdLo%$$4VVjzgei
zoqfgRCZL)^mfc#Jg31h!*-J=VW-sM2RvO!s;t{<|821_eNWijxgKzRGFVp3$BGPIX
zZ8zko6cxV~om@=OZQCi_pUt+n@iH@?U+h@8IA`+EVCdeRgdU3OAK%y7+}WyiHn#*@
zwW<e`W@$NWv3}C<b$GHR_*9FwtF^7UE7+;+=?VsQUdBnMs={%9f#Y8LqC>OKIH~C<
zjIsadE)SP49@}q@YuE|())@_ZaD$$C)Lq!<XhU(*{`f#58EPAdB*XDU=%Gk2Nb+qh
z9+C9XS2l4@uNRK_t$(WV5HNbCqSE|rD{g{n)2ridQ7OYxp7Hfik<p!JTn3)?+?VWM
ztWxpH^No&dru?kCn9Dp@Rp>sNFzs&ahOGtWb~m0B_0dNvIH%taN3Fvi><7@z<32od
VZV)q#rx{H<!%Sl>$2jhg{{SMnbC&=B

diff --git a/test/nbrowser/DocTutorial.ts b/test/nbrowser/DocTutorial.ts
index 70d1cc98..1c7d3655 100644
--- a/test/nbrowser/DocTutorial.ts
+++ b/test/nbrowser/DocTutorial.ts
@@ -13,22 +13,27 @@ describe('DocTutorial', function () {
   let doc: DocCreationInfo;
   let api: UserAPI;
   let ownerSession: gu.Session;
+  let editorSession: gu.Session;
   let viewerSession: gu.Session;
   let oldEnv: EnvironmentSnapshot;
 
-  const cleanup = setupTestSuite({team: true});
+  const cleanup = setupTestSuite({samples: true, team: true});
 
   before(async () => {
+    ownerSession = await gu.session().customTeamSite('templates').user('support').login();
+    doc = await ownerSession.tempDoc(cleanup, 'DocTutorial.grist', {load: false});
     oldEnv = new EnvironmentSnapshot();
     process.env.GRIST_UI_FEATURES = 'tutorials';
+    process.env.GRIST_TEMPLATE_ORG = 'templates';
+    process.env.GRIST_ONBOARDING_TUTORIAL_DOC_ID = doc.id;
     await server.restart();
-    ownerSession = await gu.session().teamSite.user('support').login();
-    doc = await ownerSession.tempDoc(cleanup, 'DocTutorial.grist');
+
     api = ownerSession.createHomeApi();
     await api.updateDoc(doc.id, {type: 'tutorial'});
     await api.updateDocPermissions(doc.id, {users: {
       'anon@getgrist.com': 'viewers',
       'everyone@getgrist.com': 'viewers',
+      [gu.translateUser('user1').email]: 'editors',
     }});
   });
 
@@ -43,20 +48,25 @@ describe('DocTutorial', function () {
     });
 
     it('shows a tutorial card', async function() {
-      await viewerSession.loadRelPath('/');
-      await gu.waitForDocMenuToLoad();
-      await gu.skipWelcomeQuestions();
+      await viewerSession.loadDocMenu('/');
+      assert.isTrue(await driver.find('.test-onboarding-tutorial-card').isDisplayed());
+      assert.equal(await driver.find('.test-onboarding-tutorial-percent-complete').getText(), '0%');
+    });
 
-      assert.isTrue(await driver.find('.test-tutorial-card-content').isDisplayed());
-      // Can dismiss it.
-      await driver.find('.test-tutorial-card-close').click();
-      assert.isFalse((await driver.findAll('.test-tutorial-card-content')).length > 0);
-      // When dismissed, we can see link in the menu.
+    it('can dismiss tutorial card', async function() {
+      await driver.find('.test-onboarding-dismiss-cards').click();
+      assert.isFalse(await driver.find('.test-onboarding-tutorial-card').isPresent());
+      await driver.navigate().refresh();
+      await gu.waitForDocMenuToLoad();
+      assert.isFalse(await driver.find('.test-onboarding-tutorial-card').isPresent());
+    });
+
+    it('shows a link to tutorial', async function() {
       assert.isTrue(await driver.find('.test-dm-basic-tutorial').isDisplayed());
     });
 
     it('redirects user to log in', async function() {
-      await viewerSession.loadDoc(`/doc/${doc.id}`, {wait: false});
+      await driver.find('.test-dm-basic-tutorial').click();
       await gu.checkLoginPage();
     });
   });
@@ -65,41 +75,24 @@ describe('DocTutorial', function () {
     let forkUrl: string;
 
     before(async () => {
-      ownerSession = await gu.session().teamSite.user('user1').login({showTips: true});
+      editorSession = await gu.session().customTeamSite('templates').user('user1').login({showTips: true});
+      await editorSession.loadDocMenu('/');
+      await driver.executeScript('resetDismissedPopups();');
+      await gu.waitForServer();
     });
 
     afterEach(() => gu.checkForErrors());
 
     it('shows a tutorial card', async function() {
-      await ownerSession.loadRelPath('/');
-      await gu.waitForDocMenuToLoad();
-      await gu.skipWelcomeQuestions();
-
-      // Make sure we have clean start.
-      await driver.executeScript('resetDismissedPopups();');
-      await gu.waitForServer();
-      await driver.navigate().refresh();
-      await gu.waitForDocMenuToLoad();
-
-      // Make sure we see the card.
-      assert.isTrue(await driver.find('.test-tutorial-card-content').isDisplayed());
-
-      // And can dismiss it.
-      await driver.find('.test-tutorial-card-close').click();
-      assert.isFalse((await driver.findAll('.test-tutorial-card-content')).length > 0);
-
-      // When dismissed, we can see link in the menu.
-      assert.isTrue(await driver.find('.test-dm-basic-tutorial').isDisplayed());
-
-      // Prefs are preserved after reload.
-      await driver.navigate().refresh();
-      await gu.waitForDocMenuToLoad();
-      assert.isFalse((await driver.findAll('.test-tutorial-card-content')).length > 0);
-      assert.isTrue(await driver.find('.test-dm-basic-tutorial').isDisplayed());
+      assert.isTrue(await driver.find('.test-onboarding-tutorial-card').isDisplayed());
+      await gu.waitToPass(async () =>
+        assert.equal(await driver.find('.test-onboarding-tutorial-percent-complete').getText(), '0%'),
+        2000
+      );
     });
 
     it('creates a fork the first time the document is opened', async function() {
-      await ownerSession.loadDoc(`/doc/${doc.id}`);
+      await driver.find('.test-dm-basic-tutorial').click();
       await driver.wait(async () => {
         forkUrl = await driver.getCurrentUrl();
         return /~/.test(forkUrl);
@@ -274,7 +267,7 @@ describe('DocTutorial', function () {
     });
 
     it('does not show the GristDocTutorial page or table to non-editors', async function() {
-      viewerSession = await gu.session().teamSite.user('user2').login();
+      viewerSession = await gu.session().customTeamSite('templates').user('user2').login();
       await viewerSession.loadDoc(`/doc/${doc.id}`);
       assert.deepEqual(await gu.getPageNames(), ['Page 1', 'Page 2']);
       await driver.find('.test-tools-raw').click();
@@ -300,7 +293,7 @@ describe('DocTutorial', function () {
         otherForkUrl = await driver.getCurrentUrl();
         return /~/.test(forkUrl);
       });
-      ownerSession = await gu.session().teamSite.user('user1').login();
+      editorSession = await gu.session().customTeamSite('templates').user('user1').login();
       await driver.navigate().to(otherForkUrl!);
       assert.match(await driver.findWait('.test-error-header', 2000).getText(), /Access denied/);
       await driver.navigate().to(forkUrl);
@@ -424,7 +417,7 @@ describe('DocTutorial', function () {
 
     it('remembers the last slide the user had open', async function() {
       await driver.find('.test-doc-tutorial-popup-slide-3').click();
-      // There's a 1000ms debounce in place for updates to the last slide.
+      // There's a 1000ms debounce in place when updating tutorial progress.
       await driver.sleep(1000 + 250);
       await gu.waitForServer();
       await driver.navigate().refresh();
@@ -446,7 +439,7 @@ describe('DocTutorial', function () {
       await gu.getCell(0, 1).click();
       await gu.sendKeys('Redacted', Key.ENTER);
       await gu.waitForServer();
-      await ownerSession.loadDoc(`/doc/${doc.id}`);
+      await editorSession.loadDoc(`/doc/${doc.id}`);
       let currentUrl: string;
       await driver.wait(async () => {
         currentUrl = await driver.getCurrentUrl();
@@ -456,7 +449,20 @@ describe('DocTutorial', function () {
       assert.deepEqual(await gu.getVisibleGridCells({cols: [0], rowNums: [1]}), ['Redacted']);
     });
 
+    it('tracks completion percentage', async function() {
+      await driver.find('.test-doc-tutorial-popup-end-tutorial').click();
+      await gu.waitForServer();
+      await gu.waitForDocMenuToLoad();
+      await gu.waitToPass(async () =>
+        assert.equal(await driver.find('.test-onboarding-tutorial-percent-complete').getText(), '15%'),
+        2000
+      );
+      await driver.find('.test-dm-basic-tutorial').click();
+      await gu.waitForDocToLoad();
+    });
+
     it('skips starting or resuming a tutorial if the open mode is set to default', async function() {
+      ownerSession = await gu.session().customTeamSite('templates').user('support').login();
       await ownerSession.loadDoc(`/doc/${doc.id}/m/default`);
       assert.deepEqual(await gu.getPageNames(), ['Page 1', 'Page 2', 'GristDocTutorial']);
       await driver.find('.test-tools-raw').click();
@@ -467,11 +473,14 @@ describe('DocTutorial', function () {
     });
 
     it('can restart tutorials', async function() {
-      // Simulate that the tutorial has been updated since it was forked.
-      await api.updateDoc(doc.id, {name: 'DocTutorial V2'});
-      await api.applyUserActions(doc.id, [['AddTable', 'NewTable', [{id: 'A'}]]]);
+      // Update the tutorial as the owner.
+      await driver.find('.test-bc-doc').doClick();
+      await driver.sendKeys('DocTutorial V2', Key.ENTER);
+      await gu.waitForServer();
+      await gu.addNewTable();
 
-      // Load the fork of the tutorial.
+      // Switch back to the editor's fork of the tutorial.
+      editorSession = await gu.session().customTeamSite('templates').user('user1').login();
       await driver.navigate().to(forkUrl);
       await gu.waitForDocToLoad();
       await driver.findWait('.test-doc-tutorial-popup', 2000);
@@ -503,10 +512,13 @@ describe('DocTutorial', function () {
       // Check that changes made to the tutorial since it was last started are included.
       assert.equal(await driver.find('.test-doc-tutorial-popup-header').getText(),
         'DocTutorial V2');
-      assert.deepEqual(await gu.getPageNames(), ['Page 1', 'Page 2', 'GristDocTutorial', 'NewTable']);
+      assert.deepEqual(await gu.getPageNames(), ['Page 1', 'Page 2', 'GristDocTutorial', 'Table1']);
     });
 
-    it('allows editors to replace original', async function() {
+    it('allows owners to replace original', async function() {
+      ownerSession = await gu.session().customTeamSite('templates').user('support').login();
+      await ownerSession.loadDoc(`/doc/${doc.id}`);
+
       // Make an edit to one of the tutorial slides.
       await gu.openPage('GristDocTutorial');
       await gu.getCell(1, 1).click();
@@ -532,7 +544,7 @@ describe('DocTutorial', function () {
       await gu.waitForServer();
 
       // Switch to another user and restart the tutorial.
-      viewerSession = await gu.session().teamSite.user('user2').login();
+      viewerSession = await gu.session().customTeamSite('templates').user('user2').login();
       await viewerSession.loadDoc(`/doc/${doc.id}`);
       await driver.findWait('.test-doc-tutorial-popup-restart', 2000).click();
       await driver.find('.test-modal-confirm').click();
@@ -554,13 +566,22 @@ describe('DocTutorial', function () {
       await driver.find('.test-doc-tutorial-popup-next').click();
       await gu.waitForDocMenuToLoad();
       assert.match(await driver.getCurrentUrl(), /o\/docs\/$/);
+      await gu.waitToPass(async () =>
+        assert.equal(await driver.find('.test-onboarding-tutorial-percent-complete').getText(), '0%'),
+        2000
+      );
+      await ownerSession.loadDocMenu('/');
+      await gu.waitToPass(async () =>
+        assert.equal(await driver.find('.test-onboarding-tutorial-percent-complete').getText(), '100%'),
+        2000
+      );
     });
   });
 
   describe('without tutorial flag set', function () {
     before(async () => {
       await api.updateDoc(doc.id, {type: null});
-      ownerSession = await gu.session().teamSite.user('user1').login();
+      ownerSession = await gu.session().customTeamSite('templates').user('support').login();
       await ownerSession.loadDoc(`/doc/${doc.id}`);
     });
 
@@ -568,7 +589,7 @@ describe('DocTutorial', function () {
 
     it('shows the GristDocTutorial page and table', async function() {
       assert.deepEqual(await gu.getPageNames(),
-        ['Page 1', 'Page 2', 'GristDocTutorial', 'NewTable']);
+        ['Page 1', 'Page 2', 'GristDocTutorial', 'Table1']);
       await gu.openPage('GristDocTutorial');
       assert.deepEqual(
         await gu.getVisibleGridCells({cols: [1, 2], rowNums: [1]}),
diff --git a/test/nbrowser/Features.ts b/test/nbrowser/Features.ts
index a2682716..120a53f4 100644
--- a/test/nbrowser/Features.ts
+++ b/test/nbrowser/Features.ts
@@ -33,6 +33,7 @@ describe('Features', function () {
   it('can be disabled with the GRIST_HIDE_UI_ELEMENTS env variable', async function () {
     process.env.GRIST_UI_FEATURES = 'helpCenter,tutorials';
     process.env.GRIST_HIDE_UI_ELEMENTS = 'templates';
+    process.env.GRIST_ONBOARDING_TUTORIAL_DOC_ID = 'tutorialDocId';
     await server.restart();
     await session.loadDocMenu('/');
     assert.isTrue(await driver.find('.test-left-feedback').isDisplayed());
diff --git a/test/nbrowser/HomeIntro.ts b/test/nbrowser/HomeIntro.ts
index 92ee9c58..cf14676f 100644
--- a/test/nbrowser/HomeIntro.ts
+++ b/test/nbrowser/HomeIntro.ts
@@ -52,8 +52,8 @@ describe('HomeIntro', function() {
         freshAccount: true,
       });
 
-      // Open doc-menu and dismiss the welcome questions popup
-      await session.loadDocMenu('/', 'skipWelcomeQuestions');
+      // Open doc-menu and skip onboarding
+      await session.loadDocMenu('/', 'skipOnboarding');
 
       // Reload the doc-menu and dismiss the coaching call popup
       await session.loadDocMenu('/');
@@ -83,7 +83,7 @@ describe('HomeIntro', function() {
       await session.resetSite();
 
       // Open doc-menu
-      await session.loadDocMenu('/', 'skipWelcomeQuestions');
+      await session.loadDocMenu('/');
 
       // Check message specific to logged-in user and an empty team site.
       assert.match(await driver.find('.test-welcome-title').getText(), new RegExp(`Welcome.* ${session.orgName}`));
diff --git a/test/nbrowser/gristUtils.ts b/test/nbrowser/gristUtils.ts
index 7eea6f44..fe36fbe0 100644
--- a/test/nbrowser/gristUtils.ts
+++ b/test/nbrowser/gristUtils.ts
@@ -826,8 +826,23 @@ export async function loadDoc(relPath: string, wait: boolean = true): Promise<vo
   if (wait) { await waitForDocToLoad(); }
 }
 
-export async function loadDocMenu(relPath: string, wait: boolean = true): Promise<void> {
+/**
+ * Load a DocMenu on a site.
+ *
+ * If loading for a potentially first-time user, you may give 'skipOnboarding' for second
+ * argument to skip the onboarding flow, if it gets shown.
+ */
+export async function loadDocMenu(relPath: string, wait: boolean|'skipOnboarding' = true): Promise<void> {
   await driver.get(`${server.getHost()}${relPath}`);
+  if (wait === 'skipOnboarding') {
+    const first = await Promise.race([
+      driver.findWait('.test-onboarding-page', 2000),
+      driver.findWait('.test-dm-doclist', 2000),
+    ]);
+    if (await first.matches('.test-onboarding-page')) {
+      await skipOnboarding();
+    }
+  }
   if (wait) { await waitForDocMenuToLoad(); }
 }
 
@@ -2105,7 +2120,6 @@ export class Session {
                                 freshAccount?: boolean,
                                 isFirstLogin?: boolean,
                                 showTips?: boolean,
-                                skipTutorial?: boolean, // By default true
                                 userName?: string,
                                 email?: string,
                                 retainExistingLogin?: boolean}) {
@@ -2129,11 +2143,6 @@ export class Session {
     }
     await server.simulateLogin(this.settings.name, this.settings.email, this.settings.orgDomain,
                                {isFirstLogin: false, cacheCredentials: true, ...options});
-
-    if (options?.skipTutorial ?? true) {
-      await dismissTutorialCard();
-    }
-
     return this;
   }
 
@@ -2172,17 +2181,20 @@ export class Session {
   }
 
   // Load a DocMenu on a site.
-  // If loading for a potentially first-time user, you may give 'skipWelcomeQuestions' for second
-  // argument to dismiss the popup with welcome questions, if it gets shown.
-  public async loadDocMenu(relPath: string, wait: boolean|'skipWelcomeQuestions' = true) {
+  // If loading for a potentially first-time user, you may give 'skipOnboarding' for second
+  // argument to skip the onboarding flow, if it gets shown.
+  public async loadDocMenu(relPath: string, wait: boolean|'skipOnboarding' = true) {
     await this.loadRelPath(relPath);
-    if (wait) { await waitForDocMenuToLoad(); }
-
-    if (wait === 'skipWelcomeQuestions') {
-      // When waitForDocMenuToLoad() returns, welcome questions should also render, so that we
-      // don't need to wait extra for them.
-      await skipWelcomeQuestions();
+    if (wait === 'skipOnboarding') {
+      const first = await Promise.race([
+        driver.findWait('.test-onboarding-page', 2000),
+        driver.findWait('.test-dm-doclist', 2000),
+      ]);
+      if (await first.matches('.test-onboarding-page')) {
+        await skipOnboarding();
+      }
     }
+    if (wait) { await waitForDocMenuToLoad(); }
   }
 
   public async loadRelPath(relPath: string) {
@@ -3151,21 +3163,6 @@ export async function getFilterMenuState(): Promise<FilterMenuValue[]> {
   }));
 }
 
-/**
- * Dismisses any tutorial card that might be active.
- */
-export async function dismissTutorialCard() {
-  // If there is something in our way, we can't do it.
-  if (await driver.find('.test-welcome-questions').isPresent()) {
-    return;
-  }
-  if (await driver.find('.test-tutorial-card-close').isPresent()) {
-    if (await driver.find('.test-tutorial-card-close').isDisplayed()) {
-      await driver.find('.test-tutorial-card-close').click();
-    }
-  }
-}
-
 /**
  * Dismisses coaching call if needed.
  */
@@ -3370,11 +3367,14 @@ export async function setRangeFilterBound(minMax: 'min'|'max', value: string|{re
   }
 }
 
-export async function skipWelcomeQuestions() {
-  if (await driver.find('.test-welcome-questions').isPresent()) {
-    await driver.sendKeys(Key.ESCAPE);
-    assert.equal(await driver.find('.test-welcome-questions').isPresent(), false);
-  }
+/**
+ * Skips the onboarding page that's shown to users on their first visit to the
+ * doc menu.
+ */
+export async function skipOnboarding() {
+  await driver.findWait('.test-onboarding-page', 2000);
+  await waitForServer();
+  await driver.navigate().refresh();
 }
 
 /**

From f2141851be8db43c383e9f994d346f1431c0144a Mon Sep 17 00:00:00 2001
From: George Gevoian <george@gevoian.com>
Date: Tue, 23 Jul 2024 11:13:06 -0400
Subject: [PATCH 072/145] (core) Automatically remove leaves from layout specs

Summary:
When fields or sections were being removed from Grist documents, any
layout specs that referred to them weren't being updated to no longer
do so. This mismatch was causing various buggy scenarios to manifest
in cases where the stale ids were being reused. We now automatically
update any affected layout specs whenever fields or sections are
removed.

Test Plan: Python tests.

Reviewers: jarek

Reviewed By: jarek

Differential Revision: https://phab.getgrist.com/D4302
---
 sandbox/grist/test_useractions.py | 56 +++++++++++++++++++++++++++++
 sandbox/grist/useractions.py      | 60 +++++++++++++++++++++++++++----
 2 files changed, 110 insertions(+), 6 deletions(-)

diff --git a/sandbox/grist/test_useractions.py b/sandbox/grist/test_useractions.py
index 2238028a..c6e43645 100644
--- a/sandbox/grist/test_useractions.py
+++ b/sandbox/grist/test_useractions.py
@@ -536,6 +536,12 @@ class TestUserActions(test_engine.EngineTestCase):
   def test_section_removes(self):
     # Add a couple of tables and views, to trigger creation of some related items.
     self.init_views_sample()
+    self.apply_user_action(['BulkUpdateRecord', '_grist_Views', [2], {
+      'layoutSpec': [
+        "{\"children\":[{\"children\":[{\"leaf\":10},{\"leaf\":4}]}],"
+          + "\"collapsed\":[{\"leaf\":6}]}",
+      ]
+    }])
 
     self.assertViews([
       View(1, sections=[
@@ -596,6 +602,56 @@ class TestUserActions(test_engine.EngineTestCase):
       ])
     ])
 
+    # Check that the view layout spec also got updated.
+    self.assertTableData('_grist_Views', rows='subset', cols='subset', data=[
+      ['id', 'layoutSpec'],
+      [2, "{\"children\": [{\"children\": [{\"leaf\": 4}]}], \"collapsed\": []}"],
+    ])
+
+  #----------------------------------------------------------------------
+
+  def test_field_removes(self):
+    self.init_views_sample()
+    self.apply_user_action(['BulkUpdateRecord', '_grist_Views_section', [1, 4, 6, 7], {
+      'layoutSpec': [
+        "",
+        "{\"children\":[{\"children\":[{\"leaf\":10},{\"leaf\":11}]},{\"leaf\":12}]}",
+        "{\"id\":\"d748210f-91e1-4209-8f56-c27f4f62efde\",\"type\":\"Layout\",\"children\":[{\"id"
+          + "\":\"a939f6b0-c728-4b36-bdde-6bb727ce4027\",\"type\":\"Paragraph\",\"text\":\"## **F"
+          + "orm Title**\",\"alignment\":\"center\"},{\"id\":\"428c5f4f-b1f8-4bb6-89e5-5cdab4fca5"
+          + "d0\",\"type\":\"Paragraph\",\"text\":\"Your form description goes here.\",\"alignmen"
+          + "t\":\"center\"},{\"id\":\"9bfaa427-7776-411e-9724-e351fb4d5bdf\",\"type\":\"Section"
+          + "\",\"children\":[{\"id\":\"8192f1c8-918f-4b4f-b51c-c775588a6087\",\"type\":\"Paragra"
+          + "ph\",\"text\":\"### **Header**\"},{\"id\":\"6005123f-069b-4b31-85d1-d8c677c53f7e\","
+          + "\"type\":\"Paragraph\",\"text\":\"Description\"},{\"id\":\"444ffba4-5a99-4a3f-abb9-9"
+          + "09deea5c059\",\"type\":\"Field\",\"leaf\":16},{\"id\":\"c7deaa62-3c06-45a8-aa79-122d"
+          + "01c77832\",\"type\":\"Field\",\"leaf\":17},{\"id\":\"b7326be0-a73e-4cc1-8f8b-6c5033d"
+          + "227e4\",\"type\":\"Field\",\"leaf\":18}]},{\"id\":\"3a70d09f-2b08-4875-a813-3432d2d0"
+          + "ae3d\",\"type\":\"Submit\"}]}",
+        "invalid",
+      ]
+    }])
+
+    # Remove a couple of fields. Ensure the layout specs of their sections get updated.
+    self.apply_user_action(['BulkRemoveRecord', '_grist_Views_section_field', [1, 10, 12, 16, 19]])
+    self.assertTableData('_grist_Views_section', rows='subset', cols='subset', data=[
+      ['id', 'layoutSpec'],
+      [1, ""],
+      [4, "{\"children\": [{\"children\": [{\"leaf\": 11}]}]}"],
+      [6, "{\"children\": [{\"alignment\": \"center\", \"id\": \"a939f6b0-c728-4b36-bdde-6bb727ce4"
+        + "027\", \"text\": \"## **Form Title**\", \"type\": \"Paragraph\"}, {\"alignment\": \"cen"
+        + "ter\", \"id\": \"428c5f4f-b1f8-4bb6-89e5-5cdab4fca5d0\", \"text\": \"Your form descript"
+        + "ion goes here.\", \"type\": \"Paragraph\"}, {\"children\": [{\"id\": \"8192f1c8-918f-4b"
+        + "4f-b51c-c775588a6087\", \"text\": \"### **Header**\", \"type\": \"Paragraph\"}, {\"id\""
+        + ": \"6005123f-069b-4b31-85d1-d8c677c53f7e\", \"text\": \"Description\", \"type\": \"Para"
+        + "graph\"}, {\"id\": \"c7deaa62-3c06-45a8-aa79-122d01c77832\", \"leaf\": 17, \"type\": \""
+        + "Field\"}, {\"id\": \"b7326be0-a73e-4cc1-8f8b-6c5033d227e4\", \"leaf\": 18, \"type\": \""
+        + "Field\"}], \"id\": \"9bfaa427-7776-411e-9724-e351fb4d5bdf\", \"type\": \"Section\"}, {"
+        + "\"id\": \"3a70d09f-2b08-4875-a813-3432d2d0ae3d\", \"type\": \"Submit\"}], \"id\": \"d74"
+        + "8210f-91e1-4209-8f56-c27f4f62efde\", \"type\": \"Layout\"}"],
+      [7, "invalid"],
+    ])
+
   #----------------------------------------------------------------------
 
   def test_schema_consistency_check(self):
diff --git a/sandbox/grist/useractions.py b/sandbox/grist/useractions.py
index 19bfc1c0..eb2e4c72 100644
--- a/sandbox/grist/useractions.py
+++ b/sandbox/grist/useractions.py
@@ -1267,9 +1267,8 @@ class UserActions(object):
 
     # Remove all view fields for all removed columns.
     # Bypass the check for raw data view sections.
-    field_ids = [f.id for c in col_recs for f in c.viewFields]
-
-    self.doBulkRemoveRecord("_grist_Views_section_field", field_ids)
+    fields = [f for c in col_recs for f in c.viewFields]
+    self._doRemoveViewSectionFieldRecords(fields)
 
     # If there is a displayCol, it may get auto-removed, but may first produce calc actions
     # triggered by the removal of this column. To avoid those, remove displayCols immediately.
@@ -1353,12 +1352,20 @@ class UserActions(object):
     Remove view sections, including their fields, without checking for raw view sections.
     """
     self.doBulkRemoveRecord('_grist_Views_section_field', [f.id for vs in recs for f in vs.fields])
-    self.doBulkRemoveRecord('_grist_Views_section', [r.id for r in recs])
+    views = {r.parentId.id: r.parentId for r in recs}.values()
+    rec_ids = [r.id for r in recs]
+    updates = _get_layout_spec_updates(views, set(rec_ids))
+    if updates:
+      self._do_doc_action(actions.BulkUpdateRecord('_grist_Views',
+        [row_id for (row_id, _) in updates],
+        {'layoutSpec': [value for (_, value) in updates]}
+      ))
+    self.doBulkRemoveRecord('_grist_Views_section', rec_ids)
 
   @override_action('BulkRemoveRecord', '_grist_Views_section_field')
   def _removeViewSectionFieldRecords(self, table_id, row_ids):
     """
-    Remove view sections, including their fields.
+    Remove view section fields.
     Raises an error if trying to remove a field of a table's rawViewSectionRef,
     i.e. hiding a column in a raw data widget.
     """
@@ -1366,7 +1373,21 @@ class UserActions(object):
     for rec in recs:
       if rec.parentId.isRaw:
         raise ValueError("Cannot remove raw view section field")
-    self.doBulkRemoveRecord(table_id, row_ids)
+    self._doRemoveViewSectionFieldRecords(recs)
+
+  def _doRemoveViewSectionFieldRecords(self, recs):
+    """
+    Remove view section fields, without checking for raw view section fields.
+    """
+    view_sections = {r.parentId.id: r.parentId for r in recs}.values()
+    rec_ids = [r.id for r in recs]
+    updates = _get_layout_spec_updates(view_sections, rec_ids)
+    if updates:
+      self._do_doc_action(actions.BulkUpdateRecord('_grist_Views_section',
+        [row_id for (row_id, _) in updates],
+        {'layoutSpec': [value for (_, value) in updates]}
+      ))
+    self.doBulkRemoveRecord('_grist_Views_section_field', rec_ids)
 
   #----------------------------------------
   # User actions on columns.
@@ -2361,3 +2382,30 @@ def _is_transform_col(col_id):
     'gristHelper_Transform',
     'gristHelper_Converted',
   ))
+
+def _get_layout_spec_updates(resources, removed_ids):
+  def get_patched_layout_spec(layout_spec):
+    if not isinstance(layout_spec, dict):
+      return layout_spec
+    if 'leaf' in layout_spec and layout_spec['leaf'] in removed_ids_set:
+      return None
+
+    patched_layout_spec = layout_spec.copy()
+    for key in ('children', 'collapsed'):
+      if key not in layout_spec or not isinstance(layout_spec[key], list):
+        continue
+
+      patched_values = [get_patched_layout_spec(v) for v in layout_spec[key]]
+      patched_layout_spec[key] = [v for v in patched_values if v is not None]
+    return patched_layout_spec
+
+  updates = []
+  removed_ids_set = set(removed_ids)
+  for (row_id, layout_spec) in [(r.id, r.layoutSpec) for r in resources if r.layoutSpec != ""]:
+    try:
+      layout_spec_parsed = json.loads(layout_spec)
+    except ValueError:
+      continue
+    new_layout_spec = json.dumps(get_patched_layout_spec(layout_spec_parsed), sort_keys=True)
+    updates.append((row_id, new_layout_spec))
+  return updates

From 7928ee2263a2d00d796a04ce9a0ff21f6e43f851 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= <jordigh@octave.org>
Date: Tue, 23 Jul 2024 16:16:30 -0400
Subject: [PATCH 073/145] build: update grist-ee version

---
 buildtools/.grist-ee-version | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/buildtools/.grist-ee-version b/buildtools/.grist-ee-version
index b0bb8785..85b7c695 100644
--- a/buildtools/.grist-ee-version
+++ b/buildtools/.grist-ee-version
@@ -1 +1 @@
-0.9.5
+0.9.6

From f504cfdde37b1c0358817ff72d42c72bfb886165 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= <jordigh@octave.org>
Date: Tue, 23 Jul 2024 15:31:27 -0400
Subject: [PATCH 074/145] workflows: add the enterprise code to the fly builds

I want to be able to show previews that have the enterprise toggle.
---
 .github/workflows/fly-build.yml | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/.github/workflows/fly-build.yml b/.github/workflows/fly-build.yml
index 26c5fee5..820da9fd 100644
--- a/.github/workflows/fly-build.yml
+++ b/.github/workflows/fly-build.yml
@@ -23,7 +23,8 @@ jobs:
       - name: Build and export Docker image
         id: docker-build
         run: >
-          docker build -t grist-core:preview . &&
+          ./buildtools/checkout-ext-directory.sh grist-ee &&
+          docker build -t grist-core:preview . --build-context ext=ext &&
           docker image save grist-core:preview -o grist-core.tar
       - name: Save PR information
         run: |

From 7bae7a86bf45ac66085cddd27e7562961e038688 Mon Sep 17 00:00:00 2001
From: xabirequejo <xabi.rn@gmail.com>
Date: Tue, 23 Jul 2024 12:27:23 +0000
Subject: [PATCH 075/145] Translated using Weblate (Basque)

Currently translated at 90.4% (1213 of 1341 strings)

Translation: Grist/client
Translate-URL: https://hosted.weblate.org/projects/grist/client/eu/
---
 static/locales/eu.client.json | 1095 ++++++++++++++++++++++++++++++---
 1 file changed, 1000 insertions(+), 95 deletions(-)

diff --git a/static/locales/eu.client.json b/static/locales/eu.client.json
index 1a43c679..7731b915 100644
--- a/static/locales/eu.client.json
+++ b/static/locales/eu.client.json
@@ -21,9 +21,28 @@
         "Saved": "Gordeta",
         "Special Rules": "Arau bereziak",
         "Type a message...": "Idatzi mezua…",
-        "Permission to edit document structure": "Fitxategiaren egitura editatzeko baimena",
+        "Permission to edit document structure": "Dokumentuaren egitura editatzeko baimena",
         "View As": "Ikusi honela",
-        "Add Column Rule": "Gehitu Zutabearen araua"
+        "Add Column Rule": "Gehitu Zutabearen araua",
+        "Add User Attributes": "Gehitu erabiltzailearen atributuak",
+        "Allow everyone to copy the entire document, or view it in full in fiddle mode.\nUseful for examples and templates, but not for sensitive data.": "Baimendu guztiei dokumentu osoa kopiatu edo ikustea.\nAdibide eta txantiloietarako balio du, baina ez datu sentikorretarako.",
+        "Allow everyone to view Access Rules.": "Baimendu guztiei sarbide-arauak ikustea.",
+        "Attribute name": "Atributu izena",
+        "Permission to view Access Rules": "Sarbide-arauak ikusteko baimena",
+        "Attribute to Look Up": "Bilatzeko atributua",
+        "Lookup Column": "Bilaketa zutabea",
+        "Lookup Table": "Bilaketa taula",
+        "Permission to access the document in full when needed": "Dokumentua osorik eskuratzeko baimena, beharrezkoa denean",
+        "Rules for table ": "Taula-arauak ",
+        "User Attributes": "Erabiltzailearen atributuak",
+        "Seed rules": "-",
+        "Add Table-wide Rule": "Gehitu taula osorako araua",
+        "This default should be changed if editors' access is to be limited. ": "Akats hori aldatu beharko litzateke editoreen sarbidea mugatu nahi bada. ",
+        "Remove {{- tableId }} rules": "Kendu {{- tableId}} arauak",
+        "Remove {{- name }} user attribute": "Kendu {{- name}} erabiltzailearen atributua",
+        "Remove column {{- colId }} from {{- tableId }} rules": "Kendu {{- colId }} zutabea {{- tableId }} arauetatik",
+        "When adding table rules, automatically add a rule to grant OWNER full access.": "Taula-arauak gehitzean, automatikoki gehitu arau bat JABEAri sarbide osoa emateko.",
+        "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.": "Baimendu editoreei egitura editatzea (adibidez, taulak, zutabeak, antolaketa aldatu eta ezabatzea), eta datu guztietarako sarbidea ematen duten formulak idaztea, irakurketaren murrizketak kontuan hartu gabe."
     },
     "AccountPage": {
         "API": "APIa",
@@ -39,12 +58,14 @@
         "Password & Security": "Pasahitza eta Segurtasuna",
         "Save": "Gorde",
         "Theme": "Itxura",
-        "Language": "Hizkuntza"
+        "Language": "Hizkuntza",
+        "Two-factor authentication": "Bi faktoreren autentifikazioa",
+        "Two-factor authentication is an extra layer of security for your Grist account designed to ensure that you're the only person who can access your account, even if someone knows your password.": "Bi faktoreren autentifikazioa segurtasun-geruza gehigarri bat da zure Grist kontuarentzat. Zure kontura sar daitekeen pertsona bakarra zarela ziurtatzeko diseinatuta dago, nahiz eta norbaitek zure pasahitza ezagutu."
     },
     "AccountWidget": {
         "Accounts": "Kontuak",
         "Add Account": "Gehitu kontua",
-        "Document Settings": "Fitxategiaren ezarpenak",
+        "Document Settings": "Dokumentuaren ezarpenak",
         "Manage Team": "Kudeatu Taldea",
         "Profile Settings": "Profilaren ezarpenak",
         "Sign Out": "Amaitu saioa",
@@ -53,13 +74,25 @@
         "Support Grist": "Babestu Grist",
         "Sign In": "Hasi saioa",
         "Sign Up": "Eman izena",
-        "Use This Template": "Txantiloi hau erabili"
+        "Use This Template": "Txantiloi hau erabili",
+        "Access Details": "Sarbidearen xehetasunak",
+        "Pricing": "Prezioak",
+        "Activation": "Aktibazioa",
+        "Billing Account": "Fakturazio-kontua",
+        "Toggle Mobile Mode": "Sakelako modua bai/ez",
+        "Upgrade Plan": "Hobekuntza-plana"
     },
     "ViewAsDropdown": {
-        "View As": "Ikusi honela"
+        "View As": "Ikusi honela",
+        "Users from table": "Taulako erabiltzaileak",
+        "Example Users": "Erabiltzaile-eredua"
     },
     "ActionLog": {
-        "All tables": "Taula guztiak"
+        "All tables": "Taula guztiak",
+        "Action Log failed to load": "Akzio-erregistroak kargatzeak huts egin du",
+        "This row was subsequently removed in action {{action.actionNum}}": "Errenkada hau {{action.actionNum}} ekintzaren ondorioz ezabatu da",
+        "Table {{tableId}} was subsequently removed in action #{{actionNum}}": "{{tableId}} taula #{{actionNum}} ekintza eta gero kendu da",
+        "Column {{colId}} was subsequently removed in action #{{action.actionNum}}": "{{colId}} zutabea #{{action.actionNum}} ekintza eta gero kendu da"
     },
     "AddNewButton": {
         "Add New": "Gehitu berria"
@@ -68,20 +101,29 @@
         "Click to show": "Egin klik erakusteko",
         "Create": "Sortu",
         "Remove": "Kendu",
-        "Remove API Key": "Kendu API gakoa"
+        "Remove API Key": "Kendu API gakoa",
+        "This API key can be used to access this account anonymously via the API.": "API gako hau erabiliz kontu honetara modu anonimoan sartzea dago.",
+        "By generating an API key, you will be able to make API calls for your own account.": "API gako bat sortuz gero, zure kontua eskatu ahal izango du.",
+        "This API key can be used to access your account via the API. Don’t share your API key with anyone.": "API gako hau erabiliz zure kontuan sartzea dago. Ez partekatu zure API gakoa inorekin.",
+        "You're about to delete an API key. This will cause all future requests using this API key to be rejected. Do you still want to delete?": "API gako bat ezabatzear zaude. Horrek etorkizuneko eskaera guztiak baztertuko ditu. Ziur ezabatu nahi duzula?"
     },
     "App": {
         "Description": "Deskribapena",
-        "Key": "Gakoa"
+        "Key": "Gakoa",
+        "Memory Error": "Memoria-errorea",
+        "Translators: please translate this only when your language is ready to be offered to users": "Itzultzaileak: itzuli hau zure hizkuntza erabiltzaileei eskaintzeko prest dagoenean soilik"
     },
     "AppHeader": {
         "Personal Site": "Gune pertsonala",
         "Team Site": "Taldearen gunea",
         "Grist Templates": "Grist txantiloiak",
-        "Manage Team": "Kudeatu Taldea"
+        "Manage Team": "Kudeatu Taldea",
+        "Home Page": "Hasierako orria",
+        "Billing Account": "Fakturazio-kontua",
+        "Legacy": "Legatua"
     },
     "AppModel": {
-        "This team site is suspended. Documents can be read, but not modified.": "Taldearen gunea bertan behera utzi da. Fitxategiak irakur daitezke, baina ez moldatu."
+        "This team site is suspended. Documents can be read, but not modified.": "Taldearen gunea bertan behera utzi da. Dokumentuak irakur daitezke, baina ez moldatu."
     },
     "CellContextMenu": {
         "Clear values": "Garbitu balioak",
@@ -104,11 +146,14 @@
         "Comment": "Iruzkina",
         "Copy": "Kopiatu",
         "Cut": "Ebaki",
-        "Paste": "Itsatsi"
+        "Paste": "Itsatsi",
+        "Clear cell": "Garbitu gelaxka",
+        "Filter by this value": "Iragazi balio honen arabera"
     },
     "ColorSelect": {
         "Apply": "Ezarri",
-        "Cancel": "Utzi"
+        "Cancel": "Utzi",
+        "Default cell style": "Gelaxken defektuzko estiloa"
     },
     "ColumnFilterMenu": {
         "All": "Guztia",
@@ -116,7 +161,18 @@
         "Others": "Besteak",
         "Search": "Bilatu",
         "Search values": "Bilatu balioak",
-        "None": "Bat ere ez"
+        "None": "Bat ere ez",
+        "Min": "Min.",
+        "Filter by Range": "Iragazi tartearen arabera",
+        "Max": "Max.",
+        "Start": "Hasi",
+        "End": "Amaitu",
+        "Other Values": "Beste balio batzuk",
+        "All Except": "Denak, hauek izan ezik",
+        "Other Non-Matching": "Bat ez datozen beste batzuk",
+        "Other Matching": "Bat datozen beste bat",
+        "All Shown": "Guztiak erakusten ari dira",
+        "Future Values": "Etorkizuneko balioak"
     },
     "CustomSectionConfig": {
         "Add": "Gehitu",
@@ -125,7 +181,18 @@
         "Pick a column": "Hautatu zutabea",
         "Read selected table": "Irakurri hautatutako taula",
         "Widget does not require any permissions.": "Widgetak ez du baimenik behar.",
-        "Clear selection": "Garbitu hautatutakoa"
+        "Clear selection": "Garbitu hautatutakoa",
+        "Pick a {{columnType}} column": "Aukeratu {{columnType}} zutabe bat",
+        "Learn more about custom widgets": "Ikasi gehiago norbere widgeti buruz",
+        "No document access": "Sarbiderik ez dokumentura",
+        "Widget needs to {{read}} the current table.": "Widgetek {{read}} behar du uneko taula.",
+        "Select Custom Widget": "Aukeratu Widget pertsonalizatua",
+        "Widget needs {{fullAccess}} to this document.": "Widgetak {{fullAccess}} sarbidea behar du dokumentu honetara.",
+        "{{wrongTypeCount}} non-{{columnType}} columns are not shown_one": "{{wrongTypeCount}} ez da {{columnType}} zutabea erakusten",
+        "No {{columnType}} columns in table.": "Ez dago {{columnType}} zutaberik taulan.",
+        " (optional)": " (aukerakoa)",
+        "{{wrongTypeCount}} non-{{columnType}} columns are not shown_other": "{{wrongTypeCount}} ez dira {{columnType}} zutabeak erakusten",
+        "Full document access": "Sarbide osoa dokumentura"
     },
     "DataTables": {
         "Click to copy": "Egin klik kopiatzeko",
@@ -133,83 +200,162 @@
         "Table ID copied to clipboard": "Taularen IDa arbelera kopiatu da",
         "Remove Table": "Kendu taula",
         "Rename Table": "Berrizendatu taula",
-        "You do not have edit access to this document": "Ez duzu fitxategi hau editatzeko sarbiderik"
+        "You do not have edit access to this document": "Ez duzu dokumentu hau editatzeko sarbiderik",
+        "Raw Data Tables": "Datu gordinen taulak",
+        "Edit Record Card": "Editatu erregistro-txartela",
+        "Record Card": "Erregistro-txartela",
+        "Record Card Disabled": "Erregistro-txartela ezgaituta",
+        "{{action}} Record Card": "{{action}} erregistro-txartela",
+        "Delete {{formattedTableName}} data, and remove it from all pages?": "{{formattedTableName}} datuak ezabatu eta orri guztietatik kendu?"
     },
     "DocHistory": {
         "Activity": "Jarduera",
         "Beta": "Beta",
         "Compare to Current": "Alderatu unekoarekin",
-        "Compare to Previous": "Alderatu aurrekoarekin"
+        "Compare to Previous": "Alderatu aurrekoarekin",
+        "Snapshots are unavailable.": "Argazkiak ez daude erabilgarri.",
+        "Only owners have access to snapshots for documents with access rules.": "Jabeek bakarrik eskura ditzakete sarbide-arauak dituzten dokumentuak.",
+        "Snapshots": "Argazkiak",
+        "Open Snapshot": "Ireki argazkia"
     },
     "DocMenu": {
         "Access Details": "Sarbidearen xehetasunak",
-        "All Documents": "Fitxategi guztiak",
+        "All Documents": "Dokumentu guztiak",
         "By Date Modified": "Moldatu zen dataren arabera",
         "By Name": "Izenaren arabera",
         "Delete": "Ezabatu",
         "Delete Forever": "Betiko ezabatu",
         "Delete {{name}}": "Ezabatu {{name}}",
         "Discover More Templates": "Arakatu txantiloi gehiago",
-        "Document will be moved to Trash.": "Fitxategia zakarrontzira eramango da.",
-        "Document will be permanently deleted.": "Fitxategia betiko ezabatuko da.",
-        "Documents stay in Trash for 30 days, after which they get deleted permanently.": "Fitxategiek 30 egun ematen dituzte zakarrontzian eta, ondoren, betiko ezabatzen dira.",
+        "Document will be moved to Trash.": "Dokumentua zakarrontzira eramango da.",
+        "Document will be permanently deleted.": "Dokumentua betiko ezabatuko da.",
+        "Documents stay in Trash for 30 days, after which they get deleted permanently.": "Dokumentuek 30 egun ematen dituzte zakarrontzian eta, ondoren, betiko ezabatzen dira.",
         "Examples and Templates": "Adibideak eta Txantiloiak",
         "Featured": "Ezaugarriak",
         "Manage Users": "Kudeatu erabiltzaileak",
         "More Examples and Templates": "Adibide eta Txantiloi gehiago",
         "Move": "Mugitu",
         "Other Sites": "Beste guneak",
-        "Pin Document": "Finkatu fitxategia",
-        "Pinned Documents": "Finkatutako fitxategiak",
+        "Pin Document": "Finkatu dokumentua",
+        "Pinned Documents": "Finkatutako dokumentuak",
         "Remove": "Kendu",
         "Rename": "Berrizendatu",
         "Requires edit permissions": "Editatzeko baimenak behar ditu",
         "This service is not available right now": "Zerbitzua ez dago unean erabilgarri",
         "Trash": "Zakarrontzia",
         "Trash is empty.": "Zakarrontzia hutsik dago.",
-        "Unpin Document": "Utzi fitxategia finkatzeari"
+        "Unpin Document": "Utzi dokumentua finkatzeari",
+        "(The organization needs a paid plan)": "(Erakundeak plan ordaindua behar du)",
+        "Current workspace": "Uneko lan-eremua",
+        "Deleted {{at}}": "{{at}} ezabatua",
+        "Edited {{at}}": "{{at}} editatua",
+        "Examples & Templates": "Adibideak & Txantiloiak",
+        "Move {{name}} to workspace": "Mugitu {{name}} lan-eremura",
+        "Permanently Delete \"{{name}}\"?": "Betiko ezabatu \"{{name}}\"?",
+        "Restore": "Leheneratu",
+        "To restore this document, restore the workspace first.": "Dokumentu hau leheneratzeko, leheneratu lan-eremua aurrenik.",
+        "Workspace not found": "Ez da lan-eremua aurkitu",
+        "You are on the {{siteName}} site. You also have access to the following sites:": "{{siteName}} gunean zaude. Honako gune hauetara ere sar zaitezke:",
+        "You are on your personal site. You also have access to the following sites:": "Zure leku pertsonalean zaude. Honako gune hauetara ere sar zaitezke:",
+        "You may delete a workspace forever once it has no documents in it.": "Lan-eremu bat betiko ezabatzeko ezin du barruan dokumenturik izan."
     },
     "DocPageModel": {
         "Add Empty Table": "Gehitu taula hutsa",
         "Add Page": "Gehitu orria",
         "Reload": "Birkargatu",
-        "You do not have edit access to this document": "Ez duzu fitxategi hau editatzeko sarbiderik"
+        "You do not have edit access to this document": "Ez duzu dokumentu hau editatzeko sarbiderik",
+        "Add Widget to Page": "Gehitu widgeta orrira",
+        "Document owners can attempt to recover the document. [{{error}}]": "Dokumentuen jabeak dokumentua berreskuratzen saia daitezke. [{{error}}]",
+        "Enter recovery mode": "Sartu berreskuratze moduan",
+        "Sorry, access to this document has been denied. [{{error}}]": "Barkatu, dokumentu honetarako sarbidea ukatu da. [{{error}}]",
+        "Error accessing document": "Errorea dokumentura sartzean",
+        "You can try reloading the document, or using recovery mode. Recovery mode opens the document to be fully accessible to owners, and inaccessible to others. It also disables formulas. [{{error}}]": "Dokumentua birkargatzen saia zaitezke, edo berreskuratze-modua erabiltzen. Berreskuratzeko moduak dokumentua irekitzen du jabeentzat guztiz irisgarria izateko, eta beste batzuentzat eskuraezina. Formulak ere desgaitzen ditu. [{{error}}]"
     },
     "DocumentSettings": {
         "Currency:": "Moneta:",
-        "Document Settings": "Fitxategiaren ezarpenak",
+        "Document Settings": "Dokumentuaren ezarpenak",
         "Local currency ({{currency}})": "Moneta lokala ({{currency}})",
         "Save": "Gorde",
         "Save and Reload": "Gorde eta birkargatu",
-        "This document's ID (for API use):": "Fitxategi honen IDa (APIarekin erabiltzeko):",
+        "This document's ID (for API use):": "Dokumentu honen IDa (APIarekin erabiltzeko):",
         "Time Zone:": "Ordu-eremua:",
         "API": "APIa",
-        "Document ID copied to clipboard": "Fitxategiaren IDa arbelera kopiatu da",
+        "Document ID copied to clipboard": "Dokumentuaren IDa arbelera kopiatu da",
         "Ok": "Ados",
         "API URL copied to clipboard": "APIaren URLa arbelera kopiatu da",
         "API documentation.": "APIaren dokumentazioa.",
         "Copy to clipboard": "Kopiatu arbelera",
         "Currency": "Moneta",
-        "Document ID": "Fitxategiaren IDa",
+        "Document ID": "Dokumentuaren IDa",
         "Python": "Python",
         "Python version used": "Erabiltzen ari den Python bertsioa",
         "Reload": "Birkargatu",
         "Time Zone": "Ordu-eremua",
-        "Cancel": "Utzi"
+        "Cancel": "Utzi",
+        "Locale:": "Eskualdeko ezarpenak:",
+        "Engine (experimental {{span}} change at own risk):": "Motorra (esperimentala {{span}} aldatu zure kontu):",
+        "Manage Webhooks": "Kudeatu webhookak",
+        "Webhooks": "Webhookak",
+        "API Console": "API kontsola",
+        "API console": "API kontsola",
+        "Coming soon": "Aurki",
+        "Default for DateTime columns": "DateTime zutabeetarako defektuzkoa",
+        "Base doc URL: {{docApiUrl}}": "Oinarrizko dokumentuaren URLa: {{docApiUrl}}",
+        "Data Engine": "Datu-motorra",
+        "ID for API use": "APIaren erabilerarako Ida",
+        "Locale": "Eskualdeko ezarpenak",
+        "Hard reset of data engine": "Datu-motorraren berrezarpena",
+        "Try API calls from the browser": "Saiatu APIren deiak nabigatzailetik",
+        "Reload data engine": "Birkargatu datu-motorra",
+        "Formula timer": "Formula-kronometroa",
+        "Reload data engine?": "Datu-motorra birkargatu?",
+        "Start timing": "Hasi kronometratzen",
+        "Stop timing...": "Utzi kronometratzeari...",
+        "Timing is on": "Kronometroa martxan dago",
+        "You can make changes to the document, then stop timing to see the results.": "Dokumentuan aldaketak egin ditzakezu; utzi kronometratzeari emaitzak ikusteko.",
+        "Only available to document editors": "Soilik dokumentuen editoreentzat eskuragarri",
+        "Only available to document owners": "Soilik dokumentuen jabeentzat eskuragarri",
+        "Document ID to use whenever the REST API calls for {{docId}}. See {{apiURL}}": "REST APIak {{docId}} eskatzen duen bakoitzean erabili beharreko IDa. Ikus {{apiURL}}",
+        "Find slow formulas": "Formula geldoak bilatu.",
+        "For currency columns": "Moneta zutabeetarako",
+        "For number and date formats": "Zenbaki- eta data-formatuetarako",
+        "Formula times": "Formula-denborak",
+        "Manage webhooks": "Kudeatu webhookak",
+        "Force reload the document while timing formulas, and show the result.": "Behartu dokumentua berriro kargatzera formulak kronometratu bitartean, eta erakutsi emaitzak.",
+        "Notify other services on doc changes": "Jakinarazi beste zerbitzu batzuei dokumentuak aldatzerakoan",
+        "python2 (legacy)": "python2 (legatua)",
+        "python3 (recommended)": "python3 (gomendatua)",
+        "Time reload": "Kargatu denbora"
     },
     "DocumentUsage": {
         "Attachments Size": "Eranskinen tamaina",
         "Usage": "Erabilera",
         "Data Size": "Datuen tamaina",
         "For higher limits, ": "Muga altuetarako, ",
-        "Rows": "Errenkadak"
+        "Rows": "Errenkadak",
+        "Contact the site owner to upgrade the plan to raise limits.": "Jarri gunearen jabearekin harremanetan planaren mugak handitzeko.",
+        "Usage statistics are only available to users with full access to the document data.": "Erabilera-estatistikak dokumentuen datuetarako sarbide osoa duten erabiltzaileentzat baino ez dira.",
+        "start your 30-day free trial of the Pro plan.": "Hasi 30 eguneko Pro planaren doako proba."
     },
     "DuplicateTable": {
         "Name for new table": "Taula berriaren izena",
-        "Copy all data in addition to the table structure.": "Kopiatu datu guztiak taularen egituraz gain."
+        "Copy all data in addition to the table structure.": "Kopiatu datu guztiak taularen egituraz gain.",
+        "Only the document default access rules will apply to the copy.": "Soilik kopiari aplikatuko zaizkio dokumenturako defektuzko sarbide-arauak.",
+        "Instead of duplicating tables, it's usually better to segment data using linked views. {{link}}": "Taulak bikoiztu beharrean, hobe izaten da datuak segmentatzea lotutako ikuspegiak erabiliz. {{link}}"
     },
     "ExampleInfo": {
-        "Lightweight CRM": "CRM arina"
+        "Lightweight CRM": "CRM arina",
+        "Afterschool Program": "Eskolaz kanpoko programa",
+        "Investment Research": "Inbertsioen ikerketa",
+        "Check out our related tutorial for how to link data, and create high-productivity layouts.": "Kontsultatu gure tutoriala datuak lotzeko eta produktibitate handiko antolaketak sortzeko.",
+        "Tutorial: Analyze & Visualize": "Tutoriala: aztertu eta bistaratu",
+        "Tutorial: Create a CRM": "Tutoriala: CRM bat sortu.",
+        "Tutorial: Manage Business Data": "Tutoriala: Negozio-datuak kudeatu",
+        "Welcome to the Afterschool Program template": "Ongi etorri Eskolaz kanpoko programa txantiloira",
+        "Welcome to the Investment Research template": "Ongi etorri Inbertsioen ikerketa txantiloira",
+        "Welcome to the Lightweight CRM template": "Ongi etorri CRM arinaren txantiloira",
+        "Check out our related tutorial for how to model business data, use formulas, and manage complexity.": "Kontsultatu gure tutoriala negozio-datuak modelatzeko, formulak erabiltzeko eta konplexutasuna kudeatzeko.",
+        "Check out our related tutorial to learn how to create summary tables and charts, and to link charts dynamically.": "Kontsultatu gure tutoriala laburpen-taulak eta grafikoak sortzen ikasteko eta grafikoak dinamikoki lotzeko."
     },
     "FieldConfig": {
         "Clear and reset": "Garbitu eta berrezarri",
@@ -217,7 +363,21 @@
         "Empty Columns_other": "Zutabeak hutsik daude",
         "Enter formula": "Sartu formula",
         "Set formula": "Ezarri formula",
-        "DESCRIPTION": "DESKRIBAPENA"
+        "DESCRIPTION": "DESKRIBAPENA",
+        "Convert column to data": "Bilakatu zutabea datu",
+        "Column options are limited in summary tables.": "Zutabeen aukerak laburpen-tauletan mugatuta daude.",
+        "Data Columns_other": "Datu-zutabeak",
+        "Formula Columns_one": "Formula-zutabea",
+        "Formula Columns_other": "Formula-zutabeak",
+        "Make into data column": "Bihurtu datu-zutabean",
+        "TRIGGER FORMULA": "ABIARAZI FORMULA",
+        "COLUMN BEHAVIOR": "ZUTABEAREN PORTAERA",
+        "COLUMN LABEL AND ID": "ZUTABEEN ETIKETA ETA IDa",
+        "Clear and make into formula": "Garbitu eta bilakatu formula",
+        "Convert to trigger formula": "Bilakatu formula abiarazteko",
+        "Data Columns_one": "Datu-zutabea",
+        "Mixed Behavior": "Portaera mistoa",
+        "Set trigger formula": "Ezarri abiarazlearen formula"
     },
     "FieldMenus": {
         "Revert to common settings": "Itzuli ohiko ezarpenetara",
@@ -273,11 +433,43 @@
         "Freeze {{count}} more columns_other": "Izoztu {{count}} zutabe gehiago",
         "Hide {{count}} columns_one": "Ezkutatu zutabea",
         "Detect Duplicates in...": "Antzeman bikoiztutakoak…",
-        "Choice": "Aukera"
+        "Choice": "Aukera",
+        "Convert formula to data": "Bilakatu formula datu",
+        "Sorted (#{{count}})_one": "(# {{count}}) sailkatua",
+        "Sorted (#{{count}})_other": "(# {{count}}) sailkatua",
+        "Apply on record changes": "Aplikatu erregistro-aldaketetan",
+        "Apply to new records": "Aplikatu erregistro berriei",
+        "Authorship": "Egiletza",
+        "Last Updated At": "Azken eguneraketa",
+        "Last Updated By": "Azken eguneratzailea",
+        "UUID": "UUIDa",
+        "Timestamp": "Denbora-zigilua",
+        "Add formula column": "Gehitu formula-zutabea",
+        "Created at": "Sortze-data",
+        "Created by": "Sortzailea",
+        "Last updated at": "Azken eguneraketa",
+        "DateTime": "DateTime",
+        "Reference": "Erreferentzia",
+        "Attachment": "Eranskina",
+        "Integer": "Zbk osoa",
+        "Created At": "Sortze-data",
+        "Created By": "Sortzailea",
+        "Lookups": "Bilaketak",
+        "no reference column": "Erreferentzia-zutaberik ez",
+        "Adding UUID column": "UUID zutabea gehitzen",
+        "Adding duplicates column": "Bikoiztutako zutabea gehitzen",
+        "Duplicate in {{- label}}": "{{- label}} bikoiztuta",
+        "No reference columns.": "Erreferentzia-zutaberik ez",
+        "Search columns": "Bilaketa-zutabeak",
+        "Detect duplicates in...": "Antzeman bikoizketak…",
+        "Add column with type": "Gehitu zutabea tipoarekin",
+        "Last updated by": "Azken eguneratzailea",
+        "Numeric": "Zenbakizkoa",
+        "Reference List": "Erreferentzia-zerrenda"
     },
     "HomeIntro": {
         "Browse Templates": "Arakatu txantiloiak",
-        "Create Empty Document": "Sortu fitxategi hutsa",
+        "Create Empty Document": "Sortu dokumentu hutsa",
         "Invite Team Members": "Gonbidatu taldeko kideak",
         "Visit our {{link}} to learn more.": "Bisitatu {{link}} gehiago ikasteko.",
         "Welcome to Grist!": "Ongi etorri Grist-era!",
@@ -290,19 +482,35 @@
         "To use Grist, please either sign up or sign in.": "Grist erabiltzeko eman izena edo hasi saioa.",
         "Visit our {{link}} to learn more about Grist.": "Bisitatu {{link}} Grist-i buruz gehiago ikasteko.",
         "Help Center": "Laguntza gunea",
-        "Import Document": "Inportatu fitxategiak",
+        "Import Document": "Inportatu dokumentua",
         "Welcome to {{orgName}}": "Ongi etorri {{orgName}}(e)ra",
-        "Sign up": "Eman izena"
+        "Sign up": "Eman izena",
+        "Any documents created in this site will appear here.": "Hemen agertuko dira gune honetan sortzen diren dokumentuak.",
+        "Get started by creating your first Grist document.": "Has zaitez zure lehen Grist dokumentua sortuz.",
+        "This workspace is empty.": "Lan-eremu hau hutsik dago.",
+        "You have read-only access to this site. Currently there are no documents.": "Soilik irakurtzeko sarbidea duzu gune honetan. Unean ez dago dokumenturik.",
+        "Learn more in our {{helpCenterLink}}.": "Informazio gehiago gure {{helpCenterLink}}n.",
+        "Get started by exploring templates, or creating your first Grist document.": "Has zaitez txantiloiak arakatuz edo zure lehen Grist dokumentua sortuz.",
+        "Get started by inviting your team and creating your first Grist document.": "Has zaitez zure taldea gonbidatuz eta zure lehen Grist dokumentua sortuz.",
+        "Interested in using Grist outside of your team? Visit your free ": "Grist zure taldetik kanpo erabili nahi duzu? Bisitatu zure doako ",
+        "Sprouts Program": "Kimuen programa"
     },
     "HomeLeftPane": {
-        "All Documents": "Fitxategi guztiak",
+        "All Documents": "Dokumentu guztiak",
         "Delete": "Ezabatu",
         "Examples & Templates": "Txantiloiak",
-        "Import Document": "Inportatu fitxategia",
+        "Import Document": "Inportatu dokumentua",
         "Rename": "Berrizendatu",
         "Trash": "Zakarrontzia",
         "Manage Users": "Kudeatu erabiltzaileak",
-        "Terms of service": "Zerbitzuaren baldintzak"
+        "Terms of service": "Zerbitzuaren baldintzak",
+        "Access Details": "Sarbidearen xehetasunak",
+        "Create Empty Document": "Sortu dokumentu hutsa",
+        "Create Workspace": "Sortu lan-eremua",
+        "Workspace will be moved to Trash.": "Lan-eremua zakarrontzira mugituko da.",
+        "Workspaces": "Lan-eremuak",
+        "Delete {{workspace}} and all included documents?": "{{workspace}} eta barne dituen dokumentu guztiak ezabatu?",
+        "Tutorial": "Tutoriala"
     },
     "LeftPanelCommon": {
         "Help Center": "Laguntza gunea"
@@ -316,10 +524,26 @@
         "Update": "Eguneratu",
         "Update Original": "Eguneratu jatorrizkoa",
         "Download": "Jaitsi",
-        "Download document": "Jaitsi fitxategia",
+        "Download document": "Jaitsi dokumentua",
         "Original Has Modifications": "Jatorrizkoak moldaketak ditu",
         "Enter document name": "Sartu dokumentuaren izena",
-        "Sign up": "Eman izena"
+        "Sign up": "Eman izena",
+        "No destination workspace": "Ez dago helmugako lan-eremurik",
+        "Original Looks Unrelated": "Badirudi jatorrizkoak ez duela zerikusirik",
+        "Overwrite": "Gainean idatzi",
+        "Workspace": "Lan-eremua",
+        "You do not have write access to this site": "Ez duzu gune honetarako idazketa-sarbiderik",
+        "You do not have write access to the selected workspace": "Ez duzu hautatutako lan-eremurako idazketa-sarbiderik",
+        "Download full document and history": "Jaitsi dokumentu osoa eta historia",
+        "However, it appears to be already identical.": "Hala ere, badirudi jadanik berdina dela.",
+        "Include the structure without any of the data.": "Sartu egitura, daturik gabe.",
+        "It will be overwritten, losing any content not in this document.": "Gainean idatziko da, dokumentu honetan ez dagoen edukia galduz.",
+        "The original version of this document will be updated.": "Dokumentu honen jatorrizko bertsioa eguneratuko da.",
+        "To save your changes, please sign up, then reload this page.": "Aldaketak gordetzeko, eman izena eta ondoren birkargatu orri hau.",
+        "Replacing the original requires editing rights on the original document.": "Jatorrizkoa ordezkatzeko, jatorrizko dokumentua editatzeko-eskubidea behar da.",
+        "Remove all data but keep the structure to use as a template": "Kendu datu guztiak baina gorde egitura txantiloi gisa erabiltzeko",
+        "Remove document history (can significantly reduce file size)": "Kendu dokumentuaren historia (fitxategiaren tamaina nabarmen murriztu daiteke)",
+        "Be careful, the original has changes not in this document. Those changes will be overwritten.": "Kontuz, jatorrizkoan egindako aldaketa ez daude dokumentu honetan. Aldaketa horien gainean idatziko da."
     },
     "NotifyUI": {
         "Ask for help": "Eskatu laguntza",
@@ -329,7 +553,9 @@
         "Notifications": "Jakinarazpenak",
         "Give feedback": "Eman iritzia",
         "Renew": "Berriztu",
-        "Report a problem": "Eman akats baten berri"
+        "Report a problem": "Eman akats baten berri",
+        "Manage billing": "Kudeatu fakturazioa",
+        "Upgrade Plan": "Hobetu plana"
     },
     "OnBoardingPopups": {
         "Finish": "Amaitu",
@@ -369,7 +595,7 @@
         "Row Style": "Errenkadaren estiloa",
         "Sort & Filter": "Sailkatu eta iragazi",
         "Theme": "Gaia",
-        "You do not have edit access to this document": "Ez duzu fitxategi hau editatzeko sarbiderik",
+        "You do not have edit access to this document": "Ez duzu dokumentu hau editatzeko sarbiderik",
         "Reset form": "Berrezarri formularioa",
         "Field title": "Eremuko izena",
         "Hidden field": "Ezkutuko eremua",
@@ -385,7 +611,33 @@
         "Configuration": "Konfigurazioa",
         "Enter text": "Sartu testua",
         "Field rules": "Eremuko arauak",
-        "Required field": "Beharrezko eremua"
+        "Required field": "Beharrezko eremua",
+        "Change Widget": "Aldatu widgeta",
+        "DATA TABLE": "DATU-TAULA",
+        "DATA TABLE NAME": "DATU-TAULAREN IZENA",
+        "Data": "Datuak",
+        "Detach": "Askatu",
+        "Edit Data Selection": "Editatu datuen hautaketa",
+        "CUSTOM": "PERTSONALIZATUA",
+        "SOURCE DATA": "DATUEN ITURRIA",
+        "GROUPED BY": "HONELA TALDEKATUTA",
+        "SELECT BY": "HAUTATU HONELA",
+        "Select Widget": "Hautatu widgeta",
+        "TRANSFORM": "ERALDATU",
+        "SELECTOR FOR": "HAUTATZAILEA",
+        "Series_one": "Serieak",
+        "Series_other": "Serieak",
+        "Redirect automatically after submission": "Birbideratu automatikoki bidali ondoren",
+        "Submission": "Bidalketa",
+        "Table column name": "Taularen zutabearen izena",
+        "Select a field in the form widget to configure.": "Aukeratu widgetaren formularioko eremu bat konfiguratzeko",
+        "Submit button label": "Bidaltzeko botoiaren testua",
+        "Success text": "Arrakastatsua denean erakusteko testua",
+        "WIDGET TITLE": "WIDGETAREN IZENA",
+        "Widget": "Widgeta",
+        "Add referenced columns": "Gehitu erreferentziazko zutabeak",
+        "Display button": "Erakutsi botoia",
+        "Submit another response": "Bidali beste erantzun bat"
     },
     "RowContextMenu": {
         "Delete": "Ezabatu",
@@ -393,7 +645,10 @@
         "Duplicate rows_other": "Bikoiztu errenkadak",
         "Insert row": "Txertatu errenkada",
         "Insert row above": "Txertatu errenkada gainean",
-        "Insert row below": "Txertatu errenkada azpian"
+        "Insert row below": "Txertatu errenkada azpian",
+        "Copy anchor link": "Kopiatu aingura-esteka",
+        "View as card": "Ikusi txartel gisa",
+        "Use as table headers": "Erabili taulen goiburu gisa"
     },
     "SelectionSummary": {
         "Copied to clipboard": "Arbelera kopiatu da"
@@ -403,14 +658,14 @@
         "Compare to {{termToUse}}": "Alderatu {{termToUse}}(r)ekin",
         "Current Version": "Uneko bertsioa",
         "Download": "Jaitsi",
-        "Duplicate Document": "Bikoiztu fitxategia",
+        "Duplicate Document": "Bikoiztu dokumentua",
         "Edit without affecting the original": "Editatu jatorrizkoari eragin gabe",
         "Export CSV": "Esportatu CSVa",
         "Export XLSX": "Esportatu XLSXa",
         "Manage Users": "Kudeatu erabiltzaileak",
         "Original": "Jatorrizkoa",
         "Replace {{termToUse}}...": "Ordeztu {{termToUse}}…",
-        "Save Document": "Gorde fitxategia",
+        "Save Document": "Gorde dokumentua",
         "Send to Google Drive": "Bidali Google Drivera",
         "Show in folder": "Erakutsi direktorioan",
         "Work on a Copy": "Egin lan kopia batean",
@@ -419,7 +674,12 @@
         "Export as...": "Esportatu honela…",
         "Microsoft Excel (.xlsx)": "Microsoft Excel (.xlsx)",
         "Return to {{termToUse}}": "Itzuli {{termToUse}}(e)ra",
-        "Save Copy": "Gorde kopia"
+        "Save Copy": "Gorde kopia",
+        "Back to Current": "Bueltatu unekora",
+        "Unsaved": "Gorde gabe",
+        "Comma Separated Values (.csv)": "Komaz bereizitako balioak (.csv)",
+        "DOO Separated Values (.dsv)": "DOOz bereizitako balioak (.dsv)",
+        "Tab Separated Values (.tsv)": "Tabuladorez bereizitako balioak (.tsv)"
     },
     "SiteSwitcher": {
         "Create new team site": "Sortu taldearen gune berria",
@@ -427,13 +687,18 @@
     },
     "SortConfig": {
         "Update Data": "Eguneratu datuak",
-        "Add Column": "Gehitu zutabea"
+        "Add Column": "Gehitu zutabea",
+        "Empty values last": "Balio hutsak amaieran",
+        "Natural sort": "Sailkapen naturala",
+        "Search Columns": "Bilaketa-zutabeak",
+        "Use choice position": "Erabili posizio hautatua"
     },
     "SortFilterConfig": {
         "Filter": "IRAGAZI",
         "Save": "Gorde",
         "Sort": "SAILKATU",
-        "Update Sort & Filter settings": "Eguneratu sailkatze- eta iragazketa-ezarpenak"
+        "Update Sort & Filter settings": "Eguneratu sailkatze- eta iragazketa-ezarpenak",
+        "Revert": "Itzuli"
     },
     "ThemeConfig": {
         "Appearance ": "Itxura ",
@@ -442,8 +707,17 @@
     "Tools": {
         "Access Rules": "Sarbide-arauak",
         "Delete": "Ezabatu",
-        "Document History": "Fitxategiaren historia",
-        "Settings": "Ezarpenak"
+        "Document History": "Dokumentuaren historia",
+        "Settings": "Ezarpenak",
+        "Code View": "Kode-ikustailea",
+        "Delete document tour?": "Dokumentu-bisitaldia ezabatu?",
+        "How-to Tutorial": "Tutoriala",
+        "Raw Data": "Datu gordinak",
+        "Return to viewing as yourself": "Itzuli zuk zeuk bezala ikustera",
+        "TOOLS": "TRESNAK",
+        "Tour of this Document": "Dokumentu honen bisitaldia",
+        "Validate Data": "Balioztatu datuak",
+        "API Console": "API kontsola"
     },
     "TopBar": {
         "Manage Team": "Kudeatu taldea"
@@ -453,7 +727,10 @@
         "Close": "Itxi",
         "Current field ": "Uneko eremua ",
         "Any field": "Edozein eremu",
-        "OK": "Ados"
+        "OK": "Ados",
+        "Apply on changes to:": "Aplikatu aldaketak daudenean honako hauei:",
+        "Apply to new records": "Aplikatu erregistro berrietan",
+        "Apply on record changes": "Aplikatu erregistroak aldatzerakoan"
     },
     "UserManagerModel": {
         "Editor": "Editorea",
@@ -461,12 +738,21 @@
         "Owner": "Jabea",
         "View & Edit": "Ikusi eta Editatu",
         "View Only": "Ikusi soilik",
-        "Viewer": "Ikuslea"
+        "Viewer": "Ikuslea",
+        "No Default Access": "Ez dago defektuzko sarbiderik",
+        "In Full": "Bete-betean (osorik)"
     },
     "ViewConfigTab": {
         "Form": "Formularioa",
         "Section: ": "Atala: ",
-        "Advanced settings": "Ezarpen aurreratuak"
+        "Advanced settings": "Ezarpen aurreratuak",
+        "Big tables may be marked as \"on-demand\" to avoid loading them into the data engine.": "Taula handiak \"nahieran\" gisa marka litezke datu-motorrean kargatzea saihesteko.",
+        "Compact": "Trinkotu",
+        "Edit Card Layout": "Editatu Karten antolaketa",
+        "Make On-Demand": "Egin nahieran",
+        "Plugin: ": "Plugina: ",
+        "Unmark On-Demand": "Utzi nahieran egiteari",
+        "Blocks": "Blokeak"
     },
     "ViewLayoutMenu": {
         "Advanced Sort & Filter": "Sailkapen eta iragazki aurreratua",
@@ -475,21 +761,36 @@
         "Open configuration": "Ireki konfigurazioa",
         "Show raw data": "Erakutsi datu gordinak",
         "Add to page": "Gehitu orrira",
-        "Create a form": "Sortu formularioa"
+        "Create a form": "Sortu formularioa",
+        "Print widget": "Inprimatu widgeta",
+        "Widget options": "Widgetaren aukerak",
+        "Collapse widget": "Tolestu widgeta",
+        "Copy anchor link": "Kopiatu aingura-esteka",
+        "Data selection": "Datuen hautaketa",
+        "Delete record": "Ezabatu erregistroa",
+        "Delete widget": "Ezabatu widgeta",
+        "Edit Card Layout": "Editatu Karten antolaketa"
     },
     "ViewSectionMenu": {
         "(empty)": "(hutsik)",
         "(modified)": "(moldatua)",
         "FILTER": "IRAGAZI",
         "SORT": "SAILKATU",
-        "Save": "Gorde"
+        "Save": "Gorde",
+        "(customized)": "(pertsonalizatua)",
+        "Custom options": "Aukera pertsonalizatuak",
+        "Revert": "Itzuli",
+        "Update Sort&Filter settings": "Eguneratu Sailkatu eta Irakazi ezarpenak"
     },
     "VisibleFieldsConfig": {
         "Clear": "Garbitu",
         "Hidden Fields cannot be reordered": "Ezkutatutako eremuak ezin dira berrantolatu",
         "Select All": "Hautatu guztia",
         "Hide {{label}}": "Ezkutatu {{label}}",
-        "Show {{label}}": "Erakutsi {{label}}"
+        "Show {{label}}": "Erakutsi {{label}}",
+        "Hidden {{label}}": "{{label}} ezkutatuta",
+        "Visible {{label}}": "{{label}} ikusgai",
+        "Cannot drop items into Hidden Fields": "Ezin dira ezkutatutako eremuetako elementuak deuseztatu"
     },
     "WelcomeQuestions": {
         "Education": "Hezkuntza",
@@ -499,29 +800,51 @@
         "Sales": "Salmenta",
         "Type here": "Idatzi hemen",
         "Welcome to Grist!": "Ongi etorri Grist-era!",
-        "What brings you to Grist? Please help us serve you better.": "Zerk zakartza Grist-era? Lagun gaitzazu zu hobeto zerbitzatzen."
+        "What brings you to Grist? Please help us serve you better.": "Zerk zakartza Grist-era? Lagun gaitzazu zu hobeto zerbitzatzen.",
+        "Finance & Accounting": "Finantzak eta Kontabilitatea",
+        "HR & Management": "Giza Baliabideak eta Kudeaketa",
+        "IT & Technology": "Informatika eta Teknologia",
+        "Marketing": "Marketina",
+        "Media Production": "Produkzio mediatikoa"
     },
     "WidgetTitle": {
         "Cancel": "Utzi",
-        "Save": "Gorde"
+        "Save": "Gorde",
+        "DATA TABLE NAME": "DATU-TAULAREN IZENA",
+        "Override widget title": "Idatzi gainean widgetaren izena",
+        "Provide a table name": "Ezarri taularen izena",
+        "WIDGET DESCRIPTION": "WIDGETAREN DESKRIBAPENA",
+        "WIDGET TITLE": "WIDGETAREN IZENA"
     },
     "duplicatePage": {
-        "Duplicate page {{pageName}}": "Bikoiztu {{pageName}} orria"
+        "Duplicate page {{pageName}}": "Bikoiztu {{pageName}} orria",
+        "Note that this does not copy data, but creates another view of the same data.": "Kontuan izan honek ez dituela datuak kopiatzen, baizik eta datu berberen beste ikuspegi bat sortzen duela."
     },
     "errorPages": {
         "Add account": "Gehitu kontua",
         "Go to main page": "Joan orri nagusira",
         "Sign in": "Hasi saioa",
         "Sign in again": "Hasi saioa berriro",
-        "Sign in to access this organization's documents.": "Hasi saioa erakunde honen fitxategietara sarbidea izateko.",
+        "Sign in to access this organization's documents.": "Hasi saioa erakunde honen dokumentuetara sartzeko.",
         "Something went wrong": "Zerbaitek huts egin du",
         "There was an error: {{message}}": "Errore bat egon da: {{message}}",
         "There was an unknown error.": "Errore ezezagun bat egon da.",
-        "You do not have access to this organization's documents.": "Ez duzu erakunde honen fitxategietara sarbiderik.",
+        "You do not have access to this organization's documents.": "Ez duzu erakunde honen dokumentuetara sarbiderik.",
         "Sign up": "Eman izena",
         "Your account has been deleted.": "Zure kontua ezabatu da.",
         "An unknown error occurred.": "Errore ezezagun bat gertatu da.",
-        "Form not found": "Ez da formularioa aurkitu"
+        "Form not found": "Ez da formularioa aurkitu",
+        "Access denied{{suffix}}": "Sarbidea ukatua da{{suffix}}",
+        "Error{{suffix}}": "Errorea{{suffix}}",
+        "Page not found{{suffix}}": "Ez da orria aurkitu {{suffix}}",
+        "Signed out{{suffix}}": "Saioa amaituta{{suffix}}",
+        "Contact support": "Jarri harremanetan",
+        "The requested page could not be found.{{separator}}Please check the URL and try again.": "Ezin izan da eskatutako orria aurkitu.{{separator}}Egiaztatu URLa eta saiatu berriro.",
+        "You are now signed out.": "Saioa amaitu duzu.",
+        "You are signed in as {{email}}. You can sign in with a different account, or ask an administrator for access.": "{{email}} gisa hasi duzu saioa. Beste kontu batekin hasi dezakezu saioa, edo administratzaile bati sarbidea eskatu.",
+        "Build your own form": "Sortu zure formularioa",
+        "Powered by": "Honi esker:",
+        "Account deleted{{suffix}}": "Kontua ezabatu da{{suffix}}"
     },
     "menus": {
         "Select fields": "Hautatu eremuak",
@@ -531,7 +854,15 @@
         "Date": "Data",
         "Choice": "Aukera",
         "Choice List": "Aukeren zerrenda",
-        "Attachment": "Eranskina"
+        "Attachment": "Eranskina",
+        "* Workspaces are available on team plans. ": "* Lan-eremuak TEAM planetan daude eskuragarri. ",
+        "Upgrade now": "Eguneratu orain.",
+        "Numeric": "Zenbakizkoa",
+        "Integer": "Osoa",
+        "DateTime": "Data eta Ordua",
+        "Reference": "Erreferentzia",
+        "Reference List": "Erreferentzia-zerrenda",
+        "Search columns": "Bilaketa-zutabeak"
     },
     "modals": {
         "Cancel": "Utzi",
@@ -544,18 +875,21 @@
         "Got it": "Ulertuta",
         "Don't show again": "Ez erakutsi berriro",
         "TIP": "AHOLKUA",
-        "Don't show again.": "Ez erakutsi berriro."
+        "Don't show again.": "Ez erakutsi berriro.",
+        "Are you sure you want to delete these records?": "Ziur zaude erregistro hauek ezabatu nahi dituzula?",
+        "Are you sure you want to delete this record?": "Ziur zaude erregistro hau ezabatu nahi duzula?",
+        "Undo to restore": "Desegin leheneratzeko"
     },
     "pages": {
         "Duplicate Page": "Bikoiztu orria",
         "Remove": "Kendu",
         "Rename": "Berrizendatu",
-        "You do not have edit access to this document": "Ez duzu fitxategi hau editatzeko sarbiderik"
+        "You do not have edit access to this document": "Ez duzu dokumentua editatzeko sarbiderik"
     },
     "search": {
         "Find Previous ": "Bilatu aurrekoa ",
         "No results": "Emaitzarik ez",
-        "Search in document": "Bilatu fitxategian",
+        "Search in document": "Bilatu dokumentuan",
         "Search": "Bilatu",
         "Find Next ": "Bilatu hurrengoa "
     },
@@ -563,32 +897,47 @@
         "Sending file to Google Drive": "Fitxategia Google Drivera bidaltzen"
     },
     "NTextBox": {
-        "Lines": "Lerroak"
+        "Lines": "Lerroak",
+        "false": "faltsua",
+        "true": "egia",
+        "Field Format": "Eremuaren formatua",
+        "Multi line": "Lerro bat baino gehiago",
+        "Single line": "Lerro bakarra"
     },
     "ACLUsers": {
-        "View As": "Ikusi honela"
+        "View As": "Ikusi honela",
+        "Example Users": "Adibidezko erabiltzaileak",
+        "Users from table": "Taulako erabiltzaileak"
     },
     "TypeTransform": {
         "Apply": "Ezarri",
         "Cancel": "Utzi",
-        "Preview": "Aurrebista"
+        "Preview": "Aurrebista",
+        "Revise": "Berrikusi",
+        "Update formula (Shift+Enter)": "Eguneratu formula (Shift+Enter)"
     },
     "ChoiceTextBox": {
         "CHOICES": "AUKERAK"
     },
     "ColumnEditor": {
-        "COLUMN DESCRIPTION": "ZUTABEAREN DESKRIBAPENA"
+        "COLUMN DESCRIPTION": "ZUTABEAREN DESKRIBAPENA",
+        "COLUMN LABEL": "ZUTABE ETIKETA"
     },
     "ColumnInfo": {
         "Cancel": "Utzi",
         "Save": "Gorde",
         "COLUMN DESCRIPTION": "ZUTABEAREN DESKRIBAPENA",
-        "COLUMN ID: ": "ZUTABEAREN IDa: "
+        "COLUMN ID: ": "ZUTABEAREN IDa: ",
+        "COLUMN LABEL": "ZUTABE ETIKETA"
     },
     "ConditionalStyle": {
         "Add another rule": "Gehitu beste arau bat",
         "Row Style": "Errenkadaren estiloa",
-        "IF...": "BALDIN ETA..."
+        "IF...": "BALDIN ETA...",
+        "Add conditional style": "Gehitu baldintza-estiloa",
+        "Error in style rule": "Errorea estilo-arauan",
+        "Rule must return True or False": "Arauak egia edo faltsua itzuli behar du",
+        "Conditional Style": "Baldintza-estiloa"
     },
     "DiscussionEditor": {
         "Cancel": "Utzi",
@@ -604,16 +953,32 @@
         "Resolve": "Konpondu",
         "Save": "Gorde",
         "Reply to a comment": "Erantzun iruzkin bati",
-        "Show resolved comments": "Erakutsi konpondutako iruzkinak"
+        "Show resolved comments": "Erakutsi konpondutako iruzkinak",
+        "Only my threads": "Nire hariak bakarrik",
+        "Started discussion": "Eztabaida hasi du"
     },
     "FieldBuilder": {
-        "Changing column type": "Zutabe mota aldatzen"
+        "Changing column type": "Zutabe mota aldatzen",
+        "Apply Formula to Data": "Aplikatu formula datuetan",
+        "CELL FORMAT": "GELAXKEN FORMATUA",
+        "DATA FROM TABLE": "TAULAKO DATUAK",
+        "Mixed format": "Formatu mistoa",
+        "Mixed types": "Mota mistoak",
+        "Changing multiple column types": "Hainbat zutabe mota aldatzen",
+        "Save field settings for {{colId}} as common": "{{colId}} eremuaren konfigurazioa defektuzko gisa gorde da",
+        "Use separate field settings for {{colId}}": "Erabili eremu-ezarpen bereiziak {{colId}}(e)rako",
+        "Revert field settings for {{colId}} to common": "{{colId}} eremuaren konfigurazioa defektuzkora itzuli da"
     },
     "FormulaEditor": {
         "Column or field is required": "Beharrezkoa da zutabea edo eremua",
         "Enter formula.": "Sartu formula.",
         "use AI Assistant": "erabili AA laguntzailea",
-        "Enter formula or {{button}}.": "Sartu formula edo {{button}}."
+        "Enter formula or {{button}}.": "Sartu formula edo {{button}}.",
+        "Error in the cell": "Errorea gelaxkan",
+        "Expand Editor": "Hedatu editorea",
+        "Errors in all {{numErrors}} cells": "Erroreak {{numErrors}} gelaxka guztietan",
+        "editingFormula is required": "editatuFormula beharrezkoa da",
+        "Errors in {{numErrors}} of {{numCells}} cells": "Erroreak {{numCells}}eko {{numErrors}} gelaxketan-"
     },
     "NumericTextBox": {
         "Default currency ({{defaultCurrency}})": "Defektuzko moneta ({{defaultCurrency}})",
@@ -621,24 +986,41 @@
         "Number Format": "Zenbakien formatua",
         "Decimals": "Dezimalak",
         "Currency": "Moneta",
-        "Field Format": "Eremuaren formatua"
+        "Field Format": "Eremuaren formatua",
+        "Spinner": "Spinner.",
+        "max": "max",
+        "min": "min"
     },
     "Reference": {
         "Row ID": "Errenkadaren IDa",
-        "SHOW COLUMN": "ERAKUTSI ZUTABEA"
+        "SHOW COLUMN": "ERAKUTSI ZUTABEA",
+        "CELL FORMAT": "GELAXKEN FORMATUA"
     },
     "WelcomeTour": {
         "Add New": "Gehitu berria",
         "Building up": "Sortzen",
-        "Configuring your document": "Fitxategia konfiguratzen",
+        "Configuring your document": "Dokumentua konfiguratzen",
         "Editing Data": "Datuak editatzen",
         "Enter": "Sartu",
         "Help Center": "Laguntza gunea",
-        "Use the Share button ({{share}}) to share the document or export data.": "Erabili Partekatu botoia ({{share}}) fitxategia partekatu edo datuak esportatzeko.",
+        "Use the Share button ({{share}}) to share the document or export data.": "Erabili Partekatu botoia ({{share}}) dokumentua partekatu edo datuak esportatzeko.",
         "Welcome to Grist!": "Ongi etorri Grist-era!",
         "template library": "txantiloi liburutegia",
         "Share": "Partekatu",
-        "Sharing": "Partekatzen"
+        "Sharing": "Partekatzen",
+        "Browse our {{templateLibrary}} to discover what's possible and get inspired.": "Arakatu gure {{templateLibrary}} aukerak ikusi eta inspiratzeko.",
+        "Customizing columns": "Zutabeak pertsonalizatzen",
+        "Double-click or hit {{enter}} on a cell to edit it. ": "Egin klik birritan edo sakatu {{enter}} gelaxka batean editatzeko. ",
+        "Flying higher": "Gorago hegan",
+        "Reference": "Erreferentzia",
+        "Make it relational! Use the {{ref}} type to link tables. ": "Harremanak izan daitezela! Erabili {{ref}} mota taulak lotzeko. ",
+        "Use {{helpCenter}} for documentation or questions.": "Erabili {{helpCenter}} dokumentaziorako edo galderetarako.",
+        "Use {{addNew}} to add widgets, pages, or import more data. ": "Erabili {{addNew}} widgetak, orriak edo datu gehiago inportatzeko. ",
+        "creator panel": "sortzailearen mahaigaina",
+        "Start with {{equal}} to enter a formula.": "Hasi {{equal}}ekin formula bat sartzeko.",
+        "Set formatting options, formulas, or column types, such as dates, choices, or attachments. ": "Ezarri formatu-aukerak, formulak edo zutabe-motak, hala nola datak, aukerak edo eranskinak. ",
+        "Toggle the {{creatorPanel}} to format columns, ": "Erabili {{creatorPanel}} zutabeak formatatzeko, ",
+        "convert to card view, select data, and more.": "txartel bista, datuak aukeratu, eta gehiago."
     },
     "LanguageMenu": {
         "Language": "Hizkuntza"
@@ -651,7 +1033,57 @@
         "Updates every 5 minutes.": "5 minuturo eguneratzen da.",
         "Calendar": "Egutegia",
         "Example: {{example}}": "Adibidea: {{example}}",
-        "Learn more": "Ikasi gehiago"
+        "Learn more": "Ikasi gehiago",
+        "Editing Card Layout": "Editatu karten antolaketa",
+        "Formulas that trigger in certain cases, and store the calculated value as data.": "Kasu jakin batzuetan abiarazten diren formulak, eta kalkulatutako balioak datu gisa gordetzen dute.",
+        "Link your new widget to an existing widget on this page.": "Lotu zure widget berria orrialde honetan lehendik dagoen widget batekin.",
+        "Raw Data page": "Datu gordinen orria",
+        "Rearrange the fields in your card by dragging and resizing cells.": "Berrantolatu zure txarteleko eremuak gelaxkak arrastatuz eta tamaina aldatuz.",
+        "Reference Columns": "Erreferentzia zutabeak",
+        "Reference columns are the key to {{relational}} data in Grist.": "Erreferentzia-zutabeak Grist-eko {{relational}} datuen gakoa dira.",
+        "The Raw Data page lists all data tables in your document, including summary tables and tables not included in page layouts.": "Datu gordinen orriak zure dokumentuko datu taula guztiak zerrendatzen ditu, baita laburpen-taulak eta orrialdeen antolaketetan sartu gabeko taulak ere.",
+        "Selecting Data": "Datuak hautatzen",
+        "The total size of all data in this document, excluding attachments.": "Dokumentuko datu guztien tamaina osoa, erantsitako fitxategiak alde batera utzita.",
+        "They allow for one record to point (or refer) to another.": "Erregistro batek beste bati erreferentzi egiteko (edo aipatzeko) aukera ematen dute.",
+        "You can filter by more than one column.": "Zutabe bat baino gehiago erabiliz iragaz dezakezu.",
+        "Formulas support many Excel functions, full Python syntax, and include a helpful AI Assistant.": "Formulek Excel funtzio asko onartzen dituzte, Python sintaxi osoa, eta AA laguntzaile dakarte.",
+        "Build simple forms right in Grist and share in a click with our new widget. {{learnMoreButton}}": "Sortu formulario sinpleak Grist-en eta partekatu klik batekin gure widget berriari esker. {{learnMoreButton}}",
+        "These rules are applied after all column rules have been processed, if applicable.": "Arau horiek zutabeko arau guztiak prozesatu ondoren aplikatzen dira, badagokio.",
+        "Filter displayed dropdown values with a condition.": "Iragazi baldintza batekin goitibeherak erakusten dituen balioak.",
+        "Apply conditional formatting to cells in this column when formula conditions are met.": "Aplikatu baldintza-formatua zutabe honetako gelaxkei formulako baldintzak betetzen direnean.",
+        "Apply conditional formatting to rows based on formulas.": "Aplikatu baldintza-formatua formuletan oinarritzen diren errenkadei.",
+        "Click the Add New button to create new documents or workspaces, or import data.": "Egin klik \"Gehitu berria\" botoian dokumentu edo lan-eremu berriak sortzeko, edo datuak inportatzeko.",
+        "Click on “Open row styles” to apply conditional formatting to rows.": "Egin klik \"Ireki errenkada-estiloak\"-en errenkadei formatu-baldintzak aplikatzeko.",
+        "Nested Filtering": "Iragazki habiratuak",
+        "Pinned filters are displayed as buttons above the widget.": "Finkatutako iragazkiak botoi gisa ageri dira widgetaren gainean.",
+        "Select the table containing the data to show.": "Hautatu erakutsi beharreko datuak dituen taula.",
+        "Only those rows will appear which match all of the filters.": "Iragazki guztiekin bat datozen errenkadak baino ez dira agertuko.",
+        "This is the secret to Grist's dynamic and productive layouts.": "Hau Grist-en antolaketa dinamiko eta produktiboen sekretua da.",
+        "Try out changes in a copy, then decide whether to replace the original with your edits.": "Probatu aldaketak kopia batean eta ondoren erabaki jatorrizkoa zure aldaketekin ordezkatu nahi duzun.",
+        "relational": "erlazionalak",
+        "Access Rules": "Sarbide-arauak",
+        "Access rules give you the power to create nuanced rules to determine who can see or edit which parts of your document.": "Sarbide-arauek arau zehatzak sortzeko aukera ematen dizute, zure dokumentuaren zein zati nork ikusi edo editatu dezakeen zehazteko.",
+        "Anchor Links": "Aingura-estekak",
+        "Custom Widgets": "Widget pertsonalizatuak",
+        "entire": "osorik",
+        "Useful for storing the timestamp or author of a new record, data cleaning, and more.": "Baliagarria da data-zigilu edo erregistro berri baten egilea gordetzeko, datuak garbitzeko, eta gehiago.",
+        "To make an anchor link that takes the user to a specific cell, click on a row and press {{shortcut}}.": "Erabiltzailea gelaxka zehatz batera eramaten duen aingura-esteka bat sortzeko, egin klik errenkada batean eta sakatu {{shortcut}}.",
+        "You can choose one of our pre-made widgets or embed your own by providing its full URL.": "Aldez aurretik egindako widget bat aukeratu dezakezu edo zurea txertatu URL osoa emanez.",
+        "To configure your calendar, select columns for start": {
+            "end dates and event titles. Note each column's type.": "Zure egutegia konfiguratzeko, aukeratu zutabeak hasierako/amaierako datetarako eta gertaeren izenburuetarako. Zehaztu zutabe bakoitzaren mota."
+        },
+        "A UUID is a randomly-generated string that is useful for unique identifiers and link keys.": "UUID bat ausaz sortutako kate bat da, identifikatzaile eta lotura-tekla berezietarako baliagarria dena.",
+        "Lookups return data from related tables.": "Bilaketek erlazionatutako tauletatik datuak itzultzen dituzte.",
+        "Use reference columns to relate data in different tables.": "Erabili erreferentzia-zutabeak taula ezberdinetako datuak erlazionatzeko.",
+        "You can choose from widgets available to you in the dropdown, or embed your own by providing its full URL.": "Goitibeheran dauden widgeten artean aukeratu dezakezu, edo zurea txertatu URL osoa emanez.",
+        "Use the \\u{1D6BA} icon to create summary (or pivot) tables, for totals or subtotals.": "Erabili \\u{1D6BA} ikonoa laburpen- (edo pibote-) taulak sortzeko, totaletarako edo azpi-totaletarako.",
+        "Cells in a reference column always identify an {{entire}} record in that table, but you may select which column from that record to show.": "Erreferentzia-zutabe bateko gelaxkek beti identifikatzen dute erregistro{whole}} bat taula horretan, baina erregistro horretako zein zutabe erakutsi hautatu dezakezu.",
+        "Linking Widgets": "Widgetak lotzen (erlazionatzen)",
+        "Unpin to hide the the button while keeping the filter.": "Utzi finkatzeari botoia ezkutatzeko iragazkia mantendu bitartean.",
+        "Select the table to link to.": "Hautatu lotu beharreko taula.",
+        "Use the 𝚺 icon to create summary (or pivot) tables, for totals or subtotals.": "Erabili 𝚺 ikonoa laburpen- (edo pibote-) taulak sortzeko, totaletarako edo azpi-totaletarako.",
+        "Can't find the right columns? Click 'Change Widget' to select the table with events data.": "Ez dituzu zutabe egokiak aurkitzen? Egin klik \"Aldatu widgeta\"-n gertaeren datuak dituen taula hautatzeko.",
+        "Clicking {{EyeHideIcon}} in each cell hides the field from this view without deleting it.": "Gelaxka bakoitzeko {{EyeHideIcon}}-en klik eginez gero, eremua ikuspegi honetatik ezkutatuko da ezabatu gabe."
     },
     "ColumnTitle": {
         "Column ID copied to clipboard": "Zutabearen IDa arbelera kopiatu da",
@@ -660,17 +1092,21 @@
         "Add description": "Gehitu deskribapena",
         "Close": "Itxi",
         "Cancel": "Utzi",
-        "Save": "Gorde"
+        "Save": "Gorde",
+        "Column label": "Zutabearen etiketa",
+        "Provide a column label": "Eman zutabeari etiketa bat"
     },
     "Clipboard": {
-        "Got it": "Ulertuta"
+        "Got it": "Ulertuta",
+        "Unavailable Command": "Komandoa ez dago erabilgarri"
     },
     "FieldContextMenu": {
         "Clear field": "Garbitu eremua",
         "Copy": "Kopiatu",
         "Cut": "Ebaki",
         "Hide field": "Ezkutatu eremua",
-        "Paste": "Itsatsi"
+        "Paste": "Itsatsi",
+        "Copy anchor link": "Kopiatu aingura-esteka."
     },
     "WebhookPage": {
         "Enabled": "Gaituta",
@@ -679,7 +1115,17 @@
         "Sorry, not all fields can be edited.": "Eremu guztiak ezin dira editatu.",
         "Status": "Egoera",
         "Table": "Taula",
-        "URL": "URLa"
+        "URL": "URLa",
+        "Clear Queue": "Garbitu ilara",
+        "Webhook Settings": "Webhooken ezarpenak",
+        "Cleared webhook queue.": "Webhooken ilara garbitu da.",
+        "Columns to check when update (separated by ;)": "Eguneratzerakoan egiaztatuko diren zutabeak (\";\" bidez bananduta)",
+        "Memo": "Memorandum",
+        "Removed webhook.": "Webhooka kendu da.",
+        "Webhook Id": "Webhook IDa",
+        "Ready Column": "Prest zutabea",
+        "Filter for changes in these columns (semicolon-separated ids)": "Iragazki aldaketetarako zutabe hauetan (\";\" bidez bareizi IDak)",
+        "Header Authorization": "Goiburuko baimena"
     },
     "FormulaAssistant": {
         "Ask the bot.": "Galdetu BOTari.",
@@ -697,10 +1143,35 @@
         "Save": "Gorde",
         "New Chat": "Txat berria",
         "Preview": "Aurrebista",
-        "Regenerate": "Birsortu"
+        "Regenerate": "Birsortu",
+        "Formula Help. ": "Formulen laguntza ",
+        "Function List": "Funtzioen zerrenda",
+        "Grist's AI Formula Assistance. ": "Gristen AAeko formula-laguntzailea ",
+        "Formula Cheat Sheet": "Formularen eskuliburua",
+        "Hi, I'm the Grist Formula AI Assistant.": "Kaixo, Gristen AAeko formula-laguntzailea naiz.",
+        "Code View": "Kode-ikustailea",
+        "See our {{helpFunction}} and {{formulaCheat}}, or visit our {{community}} for more help.": "Ikusi gure {{helpFunction}} eta {{formulaCheat}}, edo bisitatu gure {{community}} laguntza gehiagorako.",
+        "Press Enter to apply suggested formula.": "Sakatu Enter iradokitako formula aplikatzeko.",
+        "Sign Up for Free": "Eman izena doan",
+        "Sign up for a free Grist account to start using the Formula AI Assistant.": "Eman izena Grist doako kontu batean AA formula-laguntzailea erabiltzen hasteko.",
+        "There are some things you should know when working with me:": "Gauza batzuk jakin beharko zenituzke nirekin lan egitean:",
+        "What do you need help with?": "Zerrekin behar duzu laguntza?",
+        "Formula AI Assistant is only available for logged in users.": "AA formula-laguntzailea saioa hasitako erabiltzaileentzat bakarrik dago eskuragarri.",
+        "For higher limits, contact the site owner.": "Muga handiagoetarako, jarri harremanetan gunearen jabearekin.",
+        "For higher limits, {{upgradeNudge}}.": "Muga handiagoetarako, {{upgradeNudge}}.",
+        "upgrade to the Pro Team plan": "Pro Team planera pasa zaitez",
+        "You have {{numCredits}} remaining credits.": "{{numCredits}} kreditu gelditzen zaizkizu.",
+        "I can only help with formulas. I cannot build tables, columns, and views, or write access rules.": "Formulekin bakarrik lagundu dezaket. Ezin ditut taulak, zutabeak eta bistak sortu, ezta sarbide-arauak idatzi ere.",
+        "You have used all available credits.": "Kreditu guztiak erabili dituzu.",
+        "upgrade your plan": "hobetu zure plana"
     },
     "ChartView": {
-        "Pick a column": "Hautatu zutabea"
+        "Pick a column": "Hautatu zutabea",
+        "Create separate series for each value of the selected column.": "Sortu serie bereiziak hautatutako zutabearen balio bakoitzerako",
+        "Each Y series is followed by a series for the length of error bars.": "Y serie bakoitzaren ondoren serie bat dago errore-barren luzerarako",
+        "Toggle chart aggregation": "Grafikoaren agregazioa bai/ez",
+        "selected new group data columns": "hautatutako taldekako datu-zutabe berriak",
+        "Each Y series is followed by two series, for top and bottom error bars.": "Y serie bakoitzaren ondoren bi serie daude, goiko eta beheko errore-barretarako."
     },
     "FilterBar": {
         "SearchColumns": "Bilatu zutabeak",
@@ -709,11 +1180,29 @@
     "Importer": {
         "New Table": "Taula berria",
         "Grist column": "Grist zutabea",
-        "Import from file": "Inportatu fitxategitik"
+        "Import from file": "Inportatu fitxategitik",
+        "Merge rows that match these fields:": "Batu ondorengo eremuekin bat datozen errenkadak:",
+        "Select fields to match on": "Hautatu bat egiteko eremuak",
+        "Update existing records": "Eguneratu lehendik dauden erregistroak",
+        "{{count}} unmatched field_other": "Bat ez datozen {{count}} eremu",
+        "Column Mapping": "Zutabeen mapa",
+        "Column mapping": "Zutabeen mapa",
+        "{{count}} unmatched field in import_one": "Bat ez datorren eremu {{count}} inportazioan",
+        "{{count}} unmatched field in import_other": "Bat ez datozen {{count}} eremu inportazioan",
+        "{{count}} unmatched field_one": "Bat ez datorren eremu {{count}}",
+        "Destination table": "Helmuga-taula",
+        "Revert": "Itzuli",
+        "Skip": "Egin gabe utzi",
+        "Skip Import": "Ez inportatu",
+        "Skip Table on Import": "Ez inportatu taula",
+        "Source column": "Iturri-zutabea"
     },
     "PageWidgetPicker": {
         "Add to Page": "Gehitu orrira",
-        "Select Data": "Hautatu datuak"
+        "Select Data": "Hautatu datuak",
+        "Building {{- label}} widget": "{{- label}} widgeta sortzen",
+        "Group by": "Taldekatu honela",
+        "Select Widget": "Hautatu widgeta"
     },
     "ViewAsBanner": {
         "UnknownUser": "Erabiltzaile ezezaguna"
@@ -721,12 +1210,428 @@
     "TypeTransformation": {
         "Apply": "Ezarri",
         "Cancel": "Utzi",
-        "Preview": "Aurrebista"
+        "Preview": "Aurrebista",
+        "Revise": "Berrikusi",
+        "Update formula (Shift+Enter)": "Eguneratu formula (Shift+Enter)"
     },
     "ValidationPanel": {
-        "Rule {{length}}": "Araua {{length}}"
+        "Rule {{length}}": "Araua {{length}}",
+        "Update formula (Shift+Enter)": "Eguneratu formula (Shift+Enter)"
     },
     "DescriptionConfig": {
         "DESCRIPTION": "DESKRIBAPENA"
+    },
+    "CodeEditorPanel": {
+        "Access denied": "Sarbidea ukatu da",
+        "Code View is available only when you have full document access.": "Kode-ikustailea dokumentu guztiak eskura dituzunean bakarrik dago eskuragarri."
+    },
+    "DocTour": {
+        "No valid document tour": "Ez du balio dokumentu-ibilbideak.",
+        "Cannot construct a document tour from the data in this document. Ensure there is a table named GristDocTour with columns Title, Body, Placement, and Location.": "Ezin da dokumentu-ibilbide bat egin dokumentu honetako datuetatik abiatuta. GristDocTour izeneko mahai bat dago zutabeekin. Izenburua, gorputza, kokapena eta kokapena."
+    },
+    "Drafts": {
+        "Restore last edit": "Leheneratu azken edizioa",
+        "Undo discard": "Desegin baztertzailea"
+    },
+    "GristDoc": {
+        "Import from file": "Inportatu fitxategitik",
+        "Added new linked section to view {{viewName}}": "{{viewName}} ikusteko lotutako atal berria gehitu da",
+        "go to webhook settings": "Joan webhooken ezarpenetara",
+        "Saved linked section {{title}} in view {{name}}": "{{title}} atala gorde da hemen: {name}}"
+    },
+    "OpenVideoTour": {
+        "Grist Video Tour": "Gristen bideo-bisitaldia",
+        "Video Tour": "Bideo-bisitaldia",
+        "YouTube video player": "YouTubeko bideo-erreproduzigailua"
+    },
+    "RecordLayout": {
+        "Updating record layout.": "Erregistroen antolaketa eguneratzen."
+    },
+    "breadcrumbs": {
+        "override": "indargabetu",
+        "fiddle": "jolas zaitez",
+        "You may make edits, but they will create a new copy and will\nnot affect the original document.": "Editatu dezakezu, baina kopia berri bat sortuko da \neta ez du jatorrizko dokumentuan eraginik izango.",
+        "recovery mode": "Berreskuratze-modua",
+        "snapshot": "Argazkia",
+        "unsaved": "Gorde gabe"
+    },
+    "CellStyle": {
+        "CELL STYLE": "GELAXKA-ESTILOA",
+        "Cell Style": "Gelaxka-estiloa",
+        "Mixed style": "Estilo mistoa",
+        "Default cell style": "Gelaxken defektuzko estiloa",
+        "Open row styles": "Ireki errenkada-estiloak",
+        "Default header style": "Goiburuen defektuzko estiloa",
+        "Header Style": "Goiburu-estiloa",
+        "HEADER STYLE": "GOIBURU-ESTILOA"
+    },
+    "CurrencyPicker": {
+        "Invalid currency": "Moneta baliogabea"
+    },
+    "EditorTooltip": {
+        "Convert column to formula": "Bihurtu zutabea formula"
+    },
+    "FieldEditor": {
+        "It should be impossible to save a plain data value into a formula column": "Ezinezkoa litzateke datu-balio soil bat gordetzea formula-zutabe batean",
+        "Unable to finish saving edited cell": "Ezin izan da gelaxka gordetzen amaitu"
+    },
+    "HyperLinkEditor": {
+        "[link label] url": "[link label] URLa"
+    },
+    "DescriptionTextArea": {
+        "DESCRIPTION": "DESKRIBAPENA"
+    },
+    "UserManager": {
+        "Add {{member}} to your team": "Gehitu {{member}} zure taldean",
+        "Allow anyone with the link to open.": "Utzi esteka duen edonori irekitzen.",
+        "Confirm": "Baieztatu",
+        "Copy Link": "Kopiatu esteka",
+        "Guest": "Gonbidatua",
+        "Collaborator": "Kolaboratzaile",
+        "Invite multiple": "Gonbidatu baino gehiago",
+        "Invite people to {{resourceType}}": "Gonbidatu jendea {{resourceType}}(e)ra",
+        "Link copied to clipboard": "Esteka arbelera kopiatu da",
+        "On": "Piztuta",
+        "Once you have removed your own access,             you will not be able to get it back without assistance              from someone else with sufficient access to the {{name}}.": "Zure sarbidea kendu ondoren, ezingo duzu berreskuratu {{name}}(e)rako sarbide nahikoa duen norbaiten laguntzarik gabe.",
+        "Public access": "Sarbide publikoa",
+        "Public access inherited from {{parent}}. To remove, set 'Inherit access' option to 'None'.": "Sarbide publikoak {{parent}}(r)en baimenak hartzen ditu. Kentzeko, ezarri \"Jaso sarbidea\" \"Bat ere ez\" aukerara.",
+        "Public access: ": "Sarbide publikoa: ",
+        "Remove my access": "Kendu nire sarbidea",
+        "member": "kidea",
+        "team site": "Taldearen gunea",
+        "{{limitAt}} of {{limitTop}} {{collaborator}}s": "{{limitAt}} of {{limitTop}} {{collaborator}}s",
+        "{{collaborator}} limit exceeded": "{{collaborator}}-muga gainditu da",
+        "User inherits permissions from {{parent}}. To remove, set 'Inherit access' option to 'None'.": "Erabiltzaileak {{parent}}(r)en baimenak hartzen ditu. Kentzeko, ezarri \"Jaso sarbidea\" \"Bat ere ez\" aukerara.",
+        "Anyone with link ": "Esteka duen edonor ",
+        "Cancel": "Utzi bertan behera",
+        "Close": "Itxi",
+        "Create a team to share with more people": "Sortu talde bat jende gehiagorekin partekatzeko",
+        "Grist support": "Grist-en laguntza",
+        "Manage members of team site": "Kudeatu guneko taldeko kideak",
+        "No default access allows access to be         granted to individual documents or workspaces, rather than the full team site.": "Ez dago dokumentu indibidualetarako edo lan-guneetarako defektuzko-sarbiderik, lantaldearen gune osora baizik.",
+        "Off": "Itzalita",
+        "Open Access Rules": "Ireki sarbide-arauak",
+        "Outside collaborator": "Kanpoko kolaboratzailea",
+        "Public Access": "Sarbide publikoa",
+        "Save & ": "Gorde eta ",
+        "Team member": "Taldeko kidea",
+        "User may not modify their own access.": "Erabiltzaileak ezin du norbere sarbidea aldatu.",
+        "Your role for this team site": "Talde honen guneko zure rola",
+        "Your role for this {{resourceType}}": "{{resourceType}} honetarako zure rola",
+        "guest": "gonbidatua",
+        "No default access allows access to be granted to individual documents or workspaces, rather than the full team site.": "Ez dago dokumentu indibidualetarako edo lan-guneetarako defektuzko-sarbiderik, lantaldearen gune osora baizik.",
+        "You are about to remove your own access to this {{resourceType}}": "{{resourceType}} honetarako zure sarbidea ezabatzear zaude",
+        "User inherits permissions from {{parent})}. To remove,           set 'Inherit access' option to 'None'.": "Erabiltzaileak {{parent}} -ren baimenak heredatzen ditu. Kentzeko, ezarri \"Heredatu sarbidea\" aukera \"Bat ere ez\" aukerara.",
+        "free collaborator": "Kolaboratzaile askea",
+        "Once you have removed your own access, you will not be able to get it back without assistance from someone else with sufficient access to the {{resourceType}}.": "Zure sarbidea kendu eta gero, ezingo duzu berreskuratu {{resourceType}}(e)rako sarbidea nahikoa duen norbaiten laguntzarik gabe.",
+        "User has view access to {{resource}} resulting from manually-set access to resources inside. If removed here, this user will lose access to resources inside.": "Erabiltzaileak {{resource}}-rako bistarako sarbidea du, barneko baliabideetarako sarbidea eskuz ezartzearen ondorioz. Hemendik kenduz gero, barruan dauden baliabideak galduko ditu."
+    },
+    "SupportGristNudge": {
+        "Help Center": "Laguntza-gunea",
+        "Opt in to Telemetry": "Bidali telemetria",
+        "Support Grist": "Eman babesa Grist-i",
+        "Support Grist page": "Eman babesa Grist-en orriari",
+        "Opted In": "Onartua",
+        "Close": "Itxi",
+        "Contribute": "Hartu parte",
+        "Thank you! Your trust and support is greatly appreciated. Opt out any time from the {{link}} in the user menu.": "Mila esker! Zure konfiantza eta babesa oso estimatua da. Edozein unetan bidaltzeari utzi diezaiokezu erabiltzailearen menuko {{link}}tik.",
+        "Admin Panel": "Administratzailearen mahaigaina"
+    },
+    "SupportGristPage": {
+        "GitHub Sponsors page": "GitHub Sponsors orria",
+        "Help Center": "Laguntza-gunea",
+        "Home": "Hasiera",
+        "Support Grist": "Eman babesa Grist-i",
+        "Telemetry": "Telemetria",
+        "We only collect usage statistics, as detailed in our {{link}}, never document contents.": "Erabilera-estatistikak baino ez ditugu biltzen, gure {{link}}n zehazten den bezala; inoiz ez dokumentuen edukiak.",
+        "Sponsor": "Babeslea",
+        "GitHub": "GitHub",
+        "Opt in to Telemetry": "Bidali telemetria",
+        "Opt out of Telemetry": "Ez bidali telemetria",
+        "Sponsor Grist Labs on GitHub": "Eman babesa Grist Labs-i GitHuben",
+        "This instance is opted in to telemetry. Only the site administrator has permission to change this.": "Instantzia honek telemetria bidaltzea aukeratu du. Administratzaileak bakarrik du aukera hau aldatzeko baimena.",
+        "This instance is opted out of telemetry. Only the site administrator has permission to change this.": "Instantzia honek telemetria ez bidaltzea aukeratu du. Administratzaileak bakarrik du aukera hau aldatzeko baimena.",
+        "You can opt out of telemetry at any time from this page.": "Orri honeta telemetria ez bidaltzea aukera dezakezu edozein unetan.",
+        "You have opted in to telemetry. Thank you!": "Telemetria bidaltzea aukeratu duzu. Mila esker!",
+        "You have opted out of telemetry.": "Telemetria ez bidaltzea aukeratu duzu.",
+        "Manage Sponsorship": "Kudeatu babesletza"
+    },
+    "buildViewSectionDom": {
+        "No data": "Daturik ez",
+        "Not all data is shown": "Ez dira datu guztiak erakusten ari",
+        "No row selected in {{title}}": "Ez da errenkadarik hautatu {{title}}(e)n"
+    },
+    "FloatingEditor": {
+        "Collapse Editor": "Tolestu editorea"
+    },
+    "FloatingPopup": {
+        "Maximize": "Maximizatu",
+        "Minimize": "Minimizatu"
+    },
+    "CardContextMenu": {
+        "Copy anchor link": "Kopiatu aingura-esteka",
+        "Delete card": "Ezabatu txartela",
+        "Duplicate card": "Bikoiztu txartela",
+        "Insert card": "Txertatu txartela",
+        "Insert card above": "Txertatu txartela gainean",
+        "Insert card below": "Txertatu txartela azpian"
+    },
+    "WelcomeCoachingCall": {
+        "free coaching call": "doako coaching deia",
+        "Maybe Later": "Agian geroago",
+        "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.": "Deian zehar zure beharrak aztertuko ditugu. Grist-en oinarrizko erabilera erakutsiko dizugu, edo zure datuekin lanean hasiko gara behar dituzun taulak eraikitzeko.",
+        "Schedule Call": "Programatu deia",
+        "Schedule your {{freeCoachingCall}} with a member of our team.": "Programatu zure {{freeCoachingCall}} gure taldeko kide batekin."
+    },
+    "FormView": {
+        "Code copied to clipboard": "Kodea arbelera kopiatu da",
+        "Share this form": "Partekatu formulario hau",
+        "View": "Ikusi",
+        "Publish": "Argitaratu",
+        "Publish your form?": "Zure formularioa argitaratu?",
+        "Unpublish": "Utzi argitaratzeari",
+        "Unpublish your form?": "Zure formularioa argitaratzeari utzi?",
+        "Anyone with the link below can see the empty form and submit a response.": "Beheko esteka duen edonork ikus eta erantzun dezake formularioa.",
+        "Are you sure you want to reset your form?": "Ziur zaude formularioa berrezarri nahi duzula?",
+        "Copy code": "Kopiatu kodea",
+        "Copy link": "Kopiatu esteka",
+        "Embed this form": "Txertatu formulario hau",
+        "Link copied to clipboard": "Esteka arbelera kopiatu da",
+        "Reset": "Berrezarri",
+        "Reset form": "Berrezarri formularioa",
+        "Share": "Partekatu",
+        "Save your document to publish this form.": "Gorde dokumentua formularioa argitaratzeko.",
+        "Preview": "Aurreikusi"
+    },
+    "Editor": {
+        "Delete": "Ezabatu"
+    },
+    "Menu": {
+        "Building blocks": "Blokeak eraikitzen",
+        "Columns": "Zutabeak",
+        "Copy": "Kopiatu",
+        "Cut": "Ebaki",
+        "Insert question above": "Sartu galdera gainean",
+        "Insert question below": "Sartu galdera azpian",
+        "Paragraph": "Paragrafoa",
+        "Paste": "Itsatsi",
+        "Separator": "Bereizgailua",
+        "Unmapped fields": "Mapeatu gabeko eremuak",
+        "Header": "Goiburua"
+    },
+    "UnmappedFieldsConfig": {
+        "Clear": "Garbitu",
+        "Select All": "Hautatu guztiaa",
+        "Map fields": "Mapeatu eremuak",
+        "Mapped": "Mapeatuta",
+        "Unmap fields": "Desmapeatu eremuak",
+        "Unmapped": "Desmapeatuta"
+    },
+    "FormConfig": {
+        "Default": "Defektuzkoa",
+        "Field Format": "Eremu-formatua",
+        "Ascending": "Goranzkoa",
+        "Descending": "Beherantz",
+        "Select": "Hautatu",
+        "Vertical": "Bertikala",
+        "Radio": "Radioa",
+        "Field rules": "Eremu-arauak",
+        "Required field": "Nahitaezko eremua",
+        "Field Rules": "Eremu-arauak",
+        "Horizontal": "Horizontala",
+        "Options Alignment": "Lerrokatzea-aukerak",
+        "Options Sort Order": "Sailkatze-aukerak"
+    },
+    "FormModel": {
+        "Oops! The form you're looking for doesn't exist.": "Hara! Bilatzen ari zaren formularioa ez da existitzen.",
+        "There was a problem loading the form.": "Arazo bat egon da formularioa kargatzean.",
+        "You don't have access to this form.": "Ez duzu formulario honetarako sarbiderik.",
+        "Oops! This form is no longer published.": "Hara! Formulario hau ez dago argitaratuta egoteari utzi dio."
+    },
+    "FormPage": {
+        "There was an error submitting your form. Please try again.": "Errore bat egon da zure formularioa bidaltzean. Saiatu berriro."
+    },
+    "FormSuccessPage": {
+        "Form Submitted": "Formularioa bidali da",
+        "Thank you! Your response has been recorded.": "Mila esker! Zure erantzuna erregistratu da.",
+        "Submit new response": "Bidali erantzun berria"
+    },
+    "DateRangeOptions": {
+        "Last 7 days": "Azken 7 egunak",
+        "Last Week": "Joan den astean",
+        "Next 7 days": "Hurrengo 7 egunetan",
+        "This month": "Hilabete honetan",
+        "This week": "Aste honetan",
+        "This year": "Aurten",
+        "Today": "Gaur",
+        "Last 30 days": "Azken 30 egunak"
+    },
+    "MappedFieldsConfig": {
+        "Clear": "Garbitu",
+        "Map fields": "Mapeatu eremuak",
+        "Mapped": "Mapeatuta",
+        "Select All": "Hautatu guztia",
+        "Unmapped": "Desmapeatuta",
+        "Unmap fields": "Desmapeatu eremuak"
+    },
+    "CreateTeamModal": {
+        "Cancel": "Utzi bertan behera.",
+        "Domain name is required": "Domeinuaren izena beharrezkoa da",
+        "Go to your site": "Joan zure gunera",
+        "Team name": "Taldearen izena",
+        "Team name is required": "Taldearen izena beharrezkoa da",
+        "Work as a Team": "Egin lan taldean",
+        "Billing is not supported in grist-core": "Fakturazioa ez da bateragarria grist-core-rekin",
+        "Choose a name and url for your team site": "Aukeratu zure talderako izen eta URL bat",
+        "Create site": "Sortu gunea",
+        "Domain name is invalid": "Domeinuaren izenak ez du balio",
+        "Team site created": "Taldearen gunea sortu da",
+        "Team url": "Taldearen URLa"
+    },
+    "AdminPanel": {
+        "Current version of Grist": "Grist-en uneko bertsioa",
+        "Admin Panel": "Administratzaile Panela",
+        "Current": "Uneko",
+        "Help us make Grist better": "Lagun gaitzazu Grist hobetzen",
+        "Home": "Hasiera",
+        "Sponsor": "Babeslea",
+        "Support Grist": "Eman babesa Grist-i",
+        "Auto-check when this page loads": "Egiaztatu automatikoki orri hau kargatzen denean",
+        "Check now": "Egiaztatu orain",
+        "Checking for updates...": "Eguneraketak bilatzen...",
+        "Error": "Errorea",
+        "Error checking for updates": "Errorea eguneraketak bilatzean",
+        "Grist is up to date": "Grist egunean dago",
+        "Grist signs user session cookies with a secret key. Please set this key via the environment variable GRIST_SESSION_SECRET. Grist falls back to a hard-coded default when it is not set. We may remove this notice in the future since session IDs have been updated to be inherently cryptographically secure.": "Grist-ek erabiltzailearen saioen cookieak giltza sekretu batekin sinatzen ditu. Ezarri giltza hau GRIST_SESSION_SECRET aldagaiaren bidez. Grist kodifikazio gogorreko akats batera erortzen da ezarrita ez dagoenean. Abisu hau etorkizunean ezaba dezakegu... v1.1.16tik sortutako saio-identifikazioak kriptografikoki seguruak direlako.",
+        "Learn more.": "Ikasi gehiago.",
+        "Newer version available": "Eskuragarri dago bertsio berriago bat",
+        "OK": "Ados",
+        "Sandbox settings for data engine": "Datu-motorrarentzako sandboxing konfigurazioa",
+        "Sandboxing": "Sandboxinga",
+        "unknown": "ezezaguna",
+        "Administrator Panel Unavailable": "Administratzailearen mahaigaina ez dago erabilgarri",
+        "Authentication": "Autentifikazioa",
+        "Check failed.": "Egiaztaketak huts egin du.",
+        "Details": "Xehetasunak",
+        "Grist allows different types of authentication to be configured, including SAML and OIDC.     We recommend enabling one of these if Grist is accessible over the network or being made available     to multiple people.": "Gristek autentifikazio mota ezberdinak konfiguratzen uzten du, SAML eta OIDC barne. Horietako bat baimentzea gomendatzen dugu Grist lokaletik kanpo badago edo pertsona askoren eskura jartzen bada.",
+        "Results": "Emaitzak",
+        "Self Checks": "Norbere egiaztatzeak",
+        "Or, as a fallback, you can set: {{bootKey}} in the environment and visit: {{url}}": "Edo, falback gisa, ingurunean {{bootKey}} jar dezakezu eta {{url}} bisitatu",
+        "Grist allows different types of authentication to be configured, including SAML and OIDC. We recommend enabling one of these if Grist is accessible over the network or being made available to multiple people.": "Gristek autentifikazio mota ezberdinak konfiguratzen uzten du, SAML eta OIDC barne. Horietako bat baimentzea gomendatzen dugu Grist lokaletik kanpo badago edo pertsona askoren eskura jartzen bada.",
+        "Grist signs user session cookies with a secret key. Please set this key via the environment variable GRIST_SESSION_SECRET. Grist falls back to a hard-coded default when it is not set. We may remove this notice in the future as session IDs generated since v1.1.16 are inherently cryptographically secure.": "Grist-ek erabiltzailearen saioen cookieak giltza sekretu batekin sinatzen ditu. Ezarri giltza hau GRIST_SESSION_SECRET aldagaiaren bidez. Grist kodifikazio gogorreko akats batera erortzen da ezarrita ez dagoenean. Abisu hau etorkizunean ezaba dezakegu... v1.1.16tik sortutako saio-identifikazioak kriptografikoki seguruak direlako.",
+        "Support Grist Labs on GitHub": "Eman babesa Grist Labs-i GitHuben",
+        "Telemetry": "Telemetria",
+        "Version": "Bertsioa",
+        "Grist releases are at ": "Grist-en bertsioak hemen daude ",
+        "Last checked {{time}}": "Azken bilaketa: {{time}}",
+        "No information available": "Ez dago informaziorik",
+        "Security Settings": "Segurtasun-ezarpenak",
+        "Updates": "Eguneraketak",
+        "unconfigured": "konfiguratu gabe",
+        "Check succeeded.": "Egiaztaketa arrakastatsua.",
+        "Current authentication method": "Uneko autentifikazio-metodoa",
+        "No fault detected.": "Ez da akatsik antzeman.",
+        "Notes": "Oharrak",
+        "You do not have access to the administrator panel.\nPlease log in as an administrator.": "Ez duzu administratzaile-mahaigainera sarbiderik. Hasi saioa administratzaile gisa.",
+        "Key to sign sessions with": "Saioak sinatzeko gakoa",
+        "Grist allows for very powerful formulas, using Python. We recommend setting the environment variable GRIST_SANDBOX_FLAVOR to gvisor if your hardware supports it (most will), to run formulas in each document within a sandbox isolated from other documents and isolated from the network.": "Gristek formula oso boteretsuak onartzen ditu, Piton erabiliz. GRIST_SANDBOX_FLAVOR ingurumen aldagaia gvisorrari jartzea gomendatzen dugu, zure hardwareak eusten badio (gehienak borondatezkoak), dokumentu bakoitzean formulak exekutatzeko beste dokumentu batzuetatik isolatutako eta saretik isolatutako sandbox baten barruan.",
+        "Session Secret": "Saioaren gakoa"
+    },
+    "Columns": {
+        "Remove Column": "Kendu zutabea"
+    },
+    "Field": {
+        "No choices configured": "Ez da aukerarik konfiguratu",
+        "No values in show column of referenced table": "Ez dago baliorik erakusten den zutabean edo erreferentzia-taulan"
+    },
+    "Toggle": {
+        "Checkbox": "Aukera-kutxa.",
+        "Field Format": "Eremuaren formatua",
+        "Switch": "Aldatu"
+    },
+    "ChoiceEditor": {
+        "No choices to select": "Ez dago hautatzeko aukeratzerik",
+        "Error in dropdown condition": "Errorea goitibeherako baldintzan",
+        "No choices matching condition": "Ez dago baldintzarekin bat datorren aukerarik"
+    },
+    "ChoiceListEditor": {
+        "Error in dropdown condition": "Errorea goitibeherako baldintzan",
+        "No choices matching condition": "Ez dago baldintzarekin bat datorren aukerarik",
+        "No choices to select": "Ez dago hautatzeko aukeratzerik"
+    },
+    "DropdownConditionConfig": {
+        "Dropdown Condition": "Goitibeherako baldintza",
+        "Invalid columns: {{colIds}}": "Zutabe baliogabeak: {{colIds}}",
+        "Set dropdown condition": "Jarri goitibeherako baldintza"
+    },
+    "FormRenderer": {
+        "Search": "Bilatu",
+        "Select...": "Hautatu...",
+        "Reset": "Berrezarri",
+        "Submit": "Bidali"
+    },
+    "widgetTypesMap": {
+        "Chart": "Grafikoa",
+        "Custom": "Pertsonalizatua",
+        "Form": "Formularioa",
+        "Table": "Taula",
+        "Calendar": "Egutegia",
+        "Card": "Txartela",
+        "Card List": "Txartelen zerrenda"
+    },
+    "TimingPage": {
+        "Formula timer": "Formula-kronometroa-",
+        "Loading timing data. Don't close this tab.": "Denboren datuak kargatzen. Ez itxi fitxa hau.",
+        "Max Time (s)": "Denbora maximoa (s)",
+        "Average Time (s)": "Batez besteko denbora(k) (s)",
+        "Column ID": "Zutabearen IDa",
+        "Table ID": "Taularen IDa",
+        "Total Time (s)": "Denbora guztira (s)",
+        "Number of Calls": "Eskaera-kopurua"
+    },
+    "WelcomeSitePicker": {
+        "Welcome back": "Ongi etorri",
+        "You have access to the following Grist sites.": "Grist gune hauetarako sarbidea duzu.",
+        "You can always switch sites using the account menu.": "Beti alda ditzakezu guneak kontuaren menua erabiliz."
+    },
+    "SearchModel": {
+        "Search all pages": "Bilatu orri guztiak",
+        "Search all tables": "Bilatu taula guztiak"
+    },
+    "searchDropdown": {
+        "Search": "Bilatu"
+    },
+    "PagePanels": {
+        "Close Creator Panel": "Itxi sortzailearen mahaigaina",
+        "Open Creator Panel": "Ireki sortzailearen mahaigaina"
+    },
+    "HiddenQuestionConfig": {
+        "Hidden fields": "Ezkutatutako eremuak"
+    },
+    "CustomView": {
+        "Some required columns aren't mapped": "Nahitaezko zutabe batzuk ez daude mapeatuta",
+        "To use this widget, please map all non-optional columns from the creator panel on the right.": "Widget hau erabiltzeko, mapeatu aukerakoak ez diren zutabeak eskuineko sortzaileen mahaigainetik."
+    },
+    "FormContainer": {
+        "Build your own form": "Sortu zure formularioa.",
+        "Powered by": "Honi esker:"
+    },
+    "FormErrorPage": {
+        "Error": "Errorea"
+    },
+    "Section": {
+        "Insert section above": "Sartu atala gainean",
+        "Insert section below": "Sartu atala azpian"
+    },
+    "ReferenceUtils": {
+        "Error in dropdown condition": "Errorea goitibeherako baldintzan",
+        "No choices matching condition": "Ez dago baldintzarekin bat datorren aukerarik",
+        "No choices to select": "Ez dago hautatzeko aukeratzerik"
+    },
+    "GridView": {
+        "Click to insert": "Egin klik txertatzeko"
+    },
+    "DropdownConditionEditor": {
+        "Enter condition.": "Sartu baldintza."
     }
 }

From fc3a7f580ca3c26772969148c140082bfd54c0b7 Mon Sep 17 00:00:00 2001
From: Paul Fitzpatrick <paulfitz@alum.mit.edu>
Date: Wed, 24 Jul 2024 11:41:50 -0400
Subject: [PATCH 076/145] make access control for ConvertFromColumn action less
 brutal (#1111)

Access control for ConvertFromColumn in the presence of access rules had previously been left as a TODO. This change allows the action when the user has schema rights. Because schema rights let you create formulas, they let you read anything, so there is currently no value in nuance here.
---
 app/server/lib/GranularAccess.ts  | 28 +++++++++++-----
 test/server/lib/GranularAccess.ts | 56 +++++++++++++++++++++++++++++--
 2 files changed, 73 insertions(+), 11 deletions(-)

diff --git a/app/server/lib/GranularAccess.ts b/app/server/lib/GranularAccess.ts
index 08626869..b90e8104 100644
--- a/app/server/lib/GranularAccess.ts
+++ b/app/server/lib/GranularAccess.ts
@@ -113,8 +113,6 @@ const SPECIAL_ACTIONS = new Set(['InitNewDoc',
                                  'FillTransformRuleColIds',
                                  'TransformAndFinishImport',
                                  'AddView',
-                                 'CopyFromColumn',
-                                 'ConvertFromColumn',
                                  'AddHiddenColumn',
                                  'RespondToRequests',
                                 ]);
@@ -132,9 +130,7 @@ const OK_ACTIONS = new Set(['Calculate', 'UpdateCurrentTime']);
 // Only add an action to OTHER_RECOGNIZED_ACTIONS if you know access control
 // has been handled for it, or it is clear that access control can be done
 // by looking at the Create/Update/Delete permissions for the DocActions it
-// will create. For example, at the time of writing CopyFromColumn should
-// not be here, since it could read a column the user is not supposed to
-// have access rights to, and it is not handled specially.
+// will create.
 const OTHER_RECOGNIZED_ACTIONS = new Set([
   // Data actions.
   'AddRecord',
@@ -149,6 +145,11 @@ const OTHER_RECOGNIZED_ACTIONS = new Set([
   'AddOrUpdateRecord',
   'BulkAddOrUpdateRecord',
 
+  // Certain column actions are handled specially because of reads that
+  // don't fit the pattern of data actions.
+  'ConvertFromColumn',
+  'CopyFromColumn',
+
   // Groups of actions.
   'ApplyDocActions',
   'ApplyUndoActions',
@@ -818,7 +819,7 @@ export class GranularAccess implements GranularAccessForBundle {
     // Checks are in no particular order.
     await this._checkSimpleDataActions(docSession, actions);
     await this._checkForSpecialOrSurprisingActions(docSession, actions);
-    await this._checkPossiblePythonFormulaModification(docSession, actions);
+    await this._checkIfNeedsEarlySchemaPermission(docSession, actions);
     await this._checkDuplicateTableAccess(docSession, actions);
     await this._checkAddOrUpdateAccess(docSession, actions);
   }
@@ -912,7 +913,14 @@ export class GranularAccess implements GranularAccessForBundle {
    */
   public needEarlySchemaPermission(a: UserAction|DocAction): boolean {
     const name = a[0] as string;
-    if (name === 'ModifyColumn' || name === 'SetDisplayFormula') {
+    if (name === 'ModifyColumn' || name === 'SetDisplayFormula' ||
+        // ConvertFromColumn and CopyFromColumn are hard to reason
+        // about, especially since they appear in bundles with other
+        // actions. We throw up our hands a bit here, and just make
+        // sure the user has schema permissions. Today, in Grist, that
+        // gives a lot of power. If this gets narrowed down in future,
+        // we'll have to rethink this.
+        name === 'ConvertFromColumn' || name === 'CopyFromColumn') {
       return true;
     } else if (isDataAction(a)) {
       const tableId = getTableId(a);
@@ -1362,7 +1370,6 @@ export class GranularAccess implements GranularAccessForBundle {
     }
 
     await this._assertOnlyBundledWithSimpleDataActions(ADD_OR_UPDATE_RECORD_ACTIONS, actions);
-
     // Check for read access, and that we're not touching metadata.
     await applyToActionsRecursively(actions, async (a) => {
       if (!isAddOrUpdateRecordAction(a)) { return; }
@@ -1392,12 +1399,15 @@ export class GranularAccess implements GranularAccessForBundle {
     });
   }
 
-  private async _checkPossiblePythonFormulaModification(docSession: OptDocSession, actions: UserAction[]) {
+  private async _checkIfNeedsEarlySchemaPermission(docSession: OptDocSession, actions: UserAction[]) {
     // If changes could include Python formulas, then user must have
     // +S before we even consider passing these to the data engine.
     // Since we don't track rule or schema changes at this stage, we
     // approximate with the user's access rights at beginning of
     // bundle.
+    // We also check for +S in scenarios that are hard to break down
+    // in a more granular way, for example ConvertFromColumn and
+    // CopyFromColumn.
     if (scanActionsRecursively(actions, (a) => this.needEarlySchemaPermission(a))) {
       await this._assertSchemaAccess(docSession);
     }
diff --git a/test/server/lib/GranularAccess.ts b/test/server/lib/GranularAccess.ts
index 1748df21..87225d2d 100644
--- a/test/server/lib/GranularAccess.ts
+++ b/test/server/lib/GranularAccess.ts
@@ -457,6 +457,58 @@ describe('GranularAccess', function() {
     ]);
   });
 
+  it('respects SCHEMA_EDIT when converting a column', async () => {
+    // Initially, schema flag defaults to ON for editor.
+    await freshDoc();
+    await owner.applyUserActions(docId, [
+      ['AddTable', 'Table1', [{id: 'A', type: 'Int'},
+                              {id: 'B', type: 'Int'},
+                              {id: 'C', type: 'Int'}]],
+      ['AddRecord', '_grist_ACLResources', -1, {tableId: 'Table1', colIds: 'C'}],
+      // Add at least one access rule. Otherwise the test would succeed
+      // trivially, via shortcuts in place when the GranularAccess
+      // hasNuancedAccess test returns false. If there are no access
+      // rules present, editors can make any edit. Once a granular access
+      // rule is present, editors lose some rights that are simply too
+      // hard to compute or we haven't gotten around to.
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -1, aclFormula: 'user.Access == OWNER', permissionsText: '-R',
+      }],
+      ['AddRecord', 'Table1', null, {A: 1234, B: 1234}],
+    ]);
+
+    // Make a transformation as editor.
+    await editor.applyUserActions(docId, [
+      ['AddColumn', 'Table1', 'gristHelper_Converted', {type: 'Text', isFormula: false, visibleCol: 0, formula: ''}],
+      ['AddColumn', 'Table1', 'gristHelper_Transform',
+       {type: 'Text', isFormula: true, visibleCol: 0, formula: 'rec.gristHelper_Converted'}],
+      ["ConvertFromColumn", "Table1", "A", "gristHelper_Converted", "Text", "", 0],
+      ["CopyFromColumn", "Table1", "gristHelper_Transform", "A", "{}"],
+    ]);
+
+    // Now turn off schema flag for editor.
+    await owner.applyUserActions(docId, [
+      ['AddRecord', '_grist_ACLResources', -1, {tableId: '*', colIds: '*'}],
+      ['AddRecord', '_grist_ACLRules', null, {
+        resource: -1, aclFormula: 'user.Access == EDITOR', permissionsText: '-S',
+      }],
+    ]);
+
+    // Now prepare another transformation.
+    const transformation = [
+      ['AddColumn', 'Table1', 'gristHelper_Converted2', {type: 'Text', isFormula: false, visibleCol: 0, formula: ''}],
+      ['AddColumn', 'Table1', 'gristHelper_Transform2',
+       {type: 'Text', isFormula: true, visibleCol: 0, formula: 'rec.gristHelper_Converted2'}],
+      ["ConvertFromColumn", "Table1", "B", "gristHelper_Converted2", "Text", "", 0],
+      ["CopyFromColumn", "Table1", "gristHelper_Transform", "B", "{}"],
+    ];
+    // Should fail for editor.
+    await assert.isRejected(editor.applyUserActions(docId, transformation),
+                            /Blocked by full structure access rules/);
+    // Should go through if run as owner.
+    await assert.isFulfilled(owner.applyUserActions(docId, transformation));
+  });
+
   async function applyTransformation(colToHide: string) {
     await freshDoc();
     await owner.applyUserActions(docId, [
@@ -906,12 +958,12 @@ describe('GranularAccess', function() {
 
     await assert.isRejected(editor.applyUserActions(docId, [
       ['CopyFromColumn', 'Data1', 'A', 'B', {}],
-    ]), /need uncomplicated access/);
+    ]), /Blocked by full structure access rules/);
 
     await assert.isRejected(editor.applyUserActions(docId, [
       ['RenameColumn', 'Data1', 'B', 'B'],
       ['CopyFromColumn', 'Data1', 'A', 'B', {}],
-    ]), /need uncomplicated access/);
+    ]), /Blocked by full structure access rules/);
 
     assert.deepEqual(await editor.getDocAPI(docId).getRows('Data1'), {
       id: [ 1, 2 ],

From bb3135291c6d98523fd340d4f9836277709985c5 Mon Sep 17 00:00:00 2001
From: xabirequejo <xabi.rn@gmail.com>
Date: Wed, 24 Jul 2024 13:16:22 +0000
Subject: [PATCH 077/145] Translated using Weblate (Basque)

Currently translated at 90.5% (1214 of 1341 strings)

Translation: Grist/client
Translate-URL: https://hosted.weblate.org/projects/grist/client/eu/
---
 static/locales/eu.client.json | 32 ++++++++++++++++----------------
 1 file changed, 16 insertions(+), 16 deletions(-)

diff --git a/static/locales/eu.client.json b/static/locales/eu.client.json
index 7731b915..77bd36c0 100644
--- a/static/locales/eu.client.json
+++ b/static/locales/eu.client.json
@@ -316,7 +316,7 @@
         "Only available to document editors": "Soilik dokumentuen editoreentzat eskuragarri",
         "Only available to document owners": "Soilik dokumentuen jabeentzat eskuragarri",
         "Document ID to use whenever the REST API calls for {{docId}}. See {{apiURL}}": "REST APIak {{docId}} eskatzen duen bakoitzean erabili beharreko IDa. Ikus {{apiURL}}",
-        "Find slow formulas": "Formula geldoak bilatu.",
+        "Find slow formulas": "Formula motelak bilatu",
         "For currency columns": "Moneta zutabeetarako",
         "For number and date formats": "Zenbaki- eta data-formatuetarako",
         "Formula times": "Formula-denborak",
@@ -349,7 +349,7 @@
         "Investment Research": "Inbertsioen ikerketa",
         "Check out our related tutorial for how to link data, and create high-productivity layouts.": "Kontsultatu gure tutoriala datuak lotzeko eta produktibitate handiko antolaketak sortzeko.",
         "Tutorial: Analyze & Visualize": "Tutoriala: aztertu eta bistaratu",
-        "Tutorial: Create a CRM": "Tutoriala: CRM bat sortu.",
+        "Tutorial: Create a CRM": "Tutoriala: CRM bat sortu",
         "Tutorial: Manage Business Data": "Tutoriala: Negozio-datuak kudeatu",
         "Welcome to the Afterschool Program template": "Ongi etorri Eskolaz kanpoko programa txantiloira",
         "Welcome to the Investment Research template": "Ongi etorri Inbertsioen ikerketa txantiloira",
@@ -459,7 +459,7 @@
         "Adding UUID column": "UUID zutabea gehitzen",
         "Adding duplicates column": "Bikoiztutako zutabea gehitzen",
         "Duplicate in {{- label}}": "{{- label}} bikoiztuta",
-        "No reference columns.": "Erreferentzia-zutaberik ez",
+        "No reference columns.": "Ez dago erreferentzia-zutaberik.",
         "Search columns": "Bilaketa-zutabeak",
         "Detect duplicates in...": "Antzeman bikoizketak…",
         "Add column with type": "Gehitu zutabea tipoarekin",
@@ -630,7 +630,7 @@
         "Redirect automatically after submission": "Birbideratu automatikoki bidali ondoren",
         "Submission": "Bidalketa",
         "Table column name": "Taularen zutabearen izena",
-        "Select a field in the form widget to configure.": "Aukeratu widgetaren formularioko eremu bat konfiguratzeko",
+        "Select a field in the form widget to configure.": "Aukeratu widgetaren formularioko eremu bat konfiguratzeko.",
         "Submit button label": "Bidaltzeko botoiaren testua",
         "Success text": "Arrakastatsua denean erakusteko testua",
         "WIDGET TITLE": "WIDGETAREN IZENA",
@@ -856,7 +856,7 @@
         "Choice List": "Aukeren zerrenda",
         "Attachment": "Eranskina",
         "* Workspaces are available on team plans. ": "* Lan-eremuak TEAM planetan daude eskuragarri. ",
-        "Upgrade now": "Eguneratu orain.",
+        "Upgrade now": "Eguneratu orain",
         "Numeric": "Zenbakizkoa",
         "Integer": "Osoa",
         "DateTime": "Data eta Ordua",
@@ -987,7 +987,7 @@
         "Decimals": "Dezimalak",
         "Currency": "Moneta",
         "Field Format": "Eremuaren formatua",
-        "Spinner": "Spinner.",
+        "Spinner": "Spinner",
         "max": "max",
         "min": "min"
     },
@@ -1077,7 +1077,7 @@
         "Use reference columns to relate data in different tables.": "Erabili erreferentzia-zutabeak taula ezberdinetako datuak erlazionatzeko.",
         "You can choose from widgets available to you in the dropdown, or embed your own by providing its full URL.": "Goitibeheran dauden widgeten artean aukeratu dezakezu, edo zurea txertatu URL osoa emanez.",
         "Use the \\u{1D6BA} icon to create summary (or pivot) tables, for totals or subtotals.": "Erabili \\u{1D6BA} ikonoa laburpen- (edo pibote-) taulak sortzeko, totaletarako edo azpi-totaletarako.",
-        "Cells in a reference column always identify an {{entire}} record in that table, but you may select which column from that record to show.": "Erreferentzia-zutabe bateko gelaxkek beti identifikatzen dute erregistro{whole}} bat taula horretan, baina erregistro horretako zein zutabe erakutsi hautatu dezakezu.",
+        "Cells in a reference column always identify an {{entire}} record in that table, but you may select which column from that record to show.": "Erreferentzia-zutabe bateko gelaxkek beti identifikatzen dute erregistro {{entire}} bat taula horretan, baina erregistro horretako zein zutabe erakutsi hautatu dezakezu.",
         "Linking Widgets": "Widgetak lotzen (erlazionatzen)",
         "Unpin to hide the the button while keeping the filter.": "Utzi finkatzeari botoia ezkutatzeko iragazkia mantendu bitartean.",
         "Select the table to link to.": "Hautatu lotu beharreko taula.",
@@ -1106,7 +1106,7 @@
         "Cut": "Ebaki",
         "Hide field": "Ezkutatu eremua",
         "Paste": "Itsatsi",
-        "Copy anchor link": "Kopiatu aingura-esteka."
+        "Copy anchor link": "Kopiatu aingura-esteka"
     },
     "WebhookPage": {
         "Enabled": "Gaituta",
@@ -1167,8 +1167,8 @@
     },
     "ChartView": {
         "Pick a column": "Hautatu zutabea",
-        "Create separate series for each value of the selected column.": "Sortu serie bereiziak hautatutako zutabearen balio bakoitzerako",
-        "Each Y series is followed by a series for the length of error bars.": "Y serie bakoitzaren ondoren serie bat dago errore-barren luzerarako",
+        "Create separate series for each value of the selected column.": "Sortu serie bereiziak hautatutako zutabearen balio bakoitzerako.",
+        "Each Y series is followed by a series for the length of error bars.": "Y serie bakoitzaren ondoren serie bat dago errore-barren luzerarako.",
         "Toggle chart aggregation": "Grafikoaren agregazioa bai/ez",
         "selected new group data columns": "hautatutako taldekako datu-zutabe berriak",
         "Each Y series is followed by two series, for top and bottom error bars.": "Y serie bakoitzaren ondoren bi serie daude, goiko eta beheko errore-barretarako."
@@ -1226,7 +1226,7 @@
         "Code View is available only when you have full document access.": "Kode-ikustailea dokumentu guztiak eskura dituzunean bakarrik dago eskuragarri."
     },
     "DocTour": {
-        "No valid document tour": "Ez du balio dokumentu-ibilbideak.",
+        "No valid document tour": "Ez du balio dokumentu-ibilbideak",
         "Cannot construct a document tour from the data in this document. Ensure there is a table named GristDocTour with columns Title, Body, Placement, and Location.": "Ezin da dokumentu-ibilbide bat egin dokumentu honetako datuetatik abiatuta. GristDocTour izeneko mahai bat dago zutabeekin. Izenburua, gorputza, kokapena eta kokapena."
     },
     "Drafts": {
@@ -1237,7 +1237,7 @@
         "Import from file": "Inportatu fitxategitik",
         "Added new linked section to view {{viewName}}": "{{viewName}} ikusteko lotutako atal berria gehitu da",
         "go to webhook settings": "Joan webhooken ezarpenetara",
-        "Saved linked section {{title}} in view {{name}}": "{{title}} atala gorde da hemen: {name}}"
+        "Saved linked section {{title}} in view {{name}}": "{{title}} atala gorde da hemen: {{name}}"
     },
     "OpenVideoTour": {
         "Grist Video Tour": "Gristen bideo-bisitaldia",
@@ -1321,7 +1321,7 @@
         "guest": "gonbidatua",
         "No default access allows access to be granted to individual documents or workspaces, rather than the full team site.": "Ez dago dokumentu indibidualetarako edo lan-guneetarako defektuzko-sarbiderik, lantaldearen gune osora baizik.",
         "You are about to remove your own access to this {{resourceType}}": "{{resourceType}} honetarako zure sarbidea ezabatzear zaude",
-        "User inherits permissions from {{parent})}. To remove,           set 'Inherit access' option to 'None'.": "Erabiltzaileak {{parent}} -ren baimenak heredatzen ditu. Kentzeko, ezarri \"Heredatu sarbidea\" aukera \"Bat ere ez\" aukerara.",
+        "User inherits permissions from {{parent})}. To remove,           set 'Inherit access' option to 'None'.": "Erabiltzaileak {{parent})} -ren baimenak heredatzen ditu. Kentzeko, ezarri \"Heredatu sarbidea\" aukera \"Bat ere ez\" aukerara.",
         "free collaborator": "Kolaboratzaile askea",
         "Once you have removed your own access, you will not be able to get it back without assistance from someone else with sufficient access to the {{resourceType}}.": "Zure sarbidea kendu eta gero, ezingo duzu berreskuratu {{resourceType}}(e)rako sarbidea nahikoa duen norbaiten laguntzarik gabe.",
         "User has view access to {{resource}} resulting from manually-set access to resources inside. If removed here, this user will lose access to resources inside.": "Erabiltzaileak {{resource}}-rako bistarako sarbidea du, barneko baliabideetarako sarbidea eskuz ezartzearen ondorioz. Hemendik kenduz gero, barruan dauden baliabideak galduko ditu."
@@ -1475,7 +1475,7 @@
         "Unmap fields": "Desmapeatu eremuak"
     },
     "CreateTeamModal": {
-        "Cancel": "Utzi bertan behera.",
+        "Cancel": "Utzi",
         "Domain name is required": "Domeinuaren izena beharrezkoa da",
         "Go to your site": "Joan zure gunera",
         "Team name": "Taldearen izena",
@@ -1545,7 +1545,7 @@
         "No values in show column of referenced table": "Ez dago baliorik erakusten den zutabean edo erreferentzia-taulan"
     },
     "Toggle": {
-        "Checkbox": "Aukera-kutxa.",
+        "Checkbox": "Aukera-kutxa",
         "Field Format": "Eremuaren formatua",
         "Switch": "Aldatu"
     },
@@ -1613,7 +1613,7 @@
         "To use this widget, please map all non-optional columns from the creator panel on the right.": "Widget hau erabiltzeko, mapeatu aukerakoak ez diren zutabeak eskuineko sortzaileen mahaigainetik."
     },
     "FormContainer": {
-        "Build your own form": "Sortu zure formularioa.",
+        "Build your own form": "Sortu zure formularioa",
         "Powered by": "Honi esker:"
     },
     "FormErrorPage": {

From a9521a85449086051150a3963b6c5cfaeb967c2d Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
 <41898282+github-actions[bot]@users.noreply.github.com>
Date: Thu, 25 Jul 2024 10:32:00 -0400
Subject: [PATCH 078/145] automated update to translation keys (#1119)

Co-authored-by: Paul's Grist Bot <paul+bot@getgrist.com>
---
 static/locales/en.client.json | 32 ++++++++++++++++++++++++++++++++
 1 file changed, 32 insertions(+)

diff --git a/static/locales/en.client.json b/static/locales/en.client.json
index 6a99e738..3df3d24f 100644
--- a/static/locales/en.client.json
+++ b/static/locales/en.client.json
@@ -1633,5 +1633,37 @@
         "Number of Calls": "Number of Calls",
         "Table ID": "Table ID",
         "Total Time (s)": "Total Time (s)"
+    },
+    "DocTutorial": {
+        "Click to expand": "Click to expand",
+        "Do you want to restart the tutorial? All progress will be lost.": "Do you want to restart the tutorial? All progress will be lost.",
+        "End tutorial": "End tutorial",
+        "Finish": "Finish",
+        "Next": "Next",
+        "Previous": "Previous",
+        "Restart": "Restart"
+    },
+    "OnboardingCards": {
+        "3 minute video tour": "3 minute video tour",
+        "Complete our basics tutorial": "Complete our basics tutorial",
+        "Complete the tutorial": "Complete the tutorial",
+        "Learn the basic of reference columns, linked widgets, column types, & cards.": "Learn the basic of reference columns, linked widgets, column types, & cards."
+    },
+    "OnboardingPage": {
+        "Back": "Back",
+        "Discover Grist in 3 minutes": "Discover Grist in 3 minutes",
+        "Go hands-on with the Grist Basics tutorial": "Go hands-on with the Grist Basics tutorial",
+        "Go to the tutorial!": "Go to the tutorial!",
+        "Next step": "Next step",
+        "Skip step": "Skip step",
+        "Skip tutorial": "Skip tutorial",
+        "Tell us who you are": "Tell us who you are",
+        "Type here": "Type here",
+        "Welcome": "Welcome",
+        "What brings you to Grist (you can select multiple)?": "What brings you to Grist (you can select multiple)?",
+        "What is your role?": "What is your role?",
+        "What organization are you with?": "What organization are you with?",
+        "Your organization": "Your organization",
+        "Your role": "Your role"
     }
 }

From 61942f6f4bc53dbacb943a094edcbfc905bbd49f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jaros=C5=82aw=20Sadzi=C5=84ski?=
 <jaroslaw.sadzinski@gmail.com>
Date: Wed, 24 Jul 2024 13:33:53 +0200
Subject: [PATCH 079/145] (core) Adding confirmation before remove last widget
 for a table

Summary:
When last widget for a table is removed, user is informed
about that and can decide between removing the widget and removing
both table and widget

Test Plan: Updated

Reviewers: georgegevoian

Reviewed By: georgegevoian

Differential Revision: https://phab.getgrist.com/D4295
---
 app/client/components/LayoutTray.ts          |  33 ++---
 app/client/components/ViewLayout.ts          | 120 +++++++++++++++++--
 app/client/models/entities/TableRec.ts       |   3 +
 app/client/models/entities/ViewSectionRec.ts |   5 +-
 test/nbrowser/SelectBySummary.ts             |   4 +-
 test/nbrowser/ViewLayoutCollapse.ts          |  98 +++++++++++++++
 test/nbrowser/Views.ntest.js                 |   3 +-
 test/nbrowser/gristUtils.ts                  |  13 ++
 8 files changed, 249 insertions(+), 30 deletions(-)

diff --git a/app/client/components/LayoutTray.ts b/app/client/components/LayoutTray.ts
index 99c92941..d79453ef 100644
--- a/app/client/components/LayoutTray.ts
+++ b/app/client/components/LayoutTray.ts
@@ -195,7 +195,7 @@ export class LayoutTray extends DisposableWithEvents {
         box.dispose();
 
         // And ask the viewLayout to save the specs.
-        viewLayout.saveLayoutSpec();
+        viewLayout.saveLayoutSpec().catch(reportError);
       },
       restoreSection: () => {
         // Get the section that is collapsed and clicked (we are setting this value).
@@ -206,23 +206,28 @@ export class LayoutTray extends DisposableWithEvents {
           viewLayout.viewModel.activeCollapsedSections.peek().filter(x => x !== leafId)
         );
         viewLayout.viewModel.activeSectionId(leafId);
-        viewLayout.saveLayoutSpec();
+        viewLayout.saveLayoutSpec().catch(reportError);
       },
       // Delete collapsed section.
-      deleteCollapsedSection: () => {
+      deleteCollapsedSection: async () => {
         // This section is still in the view (but not in the layout). So we can just remove it.
         const leafId = viewLayout.viewModel.activeCollapsedSectionId();
         if (!leafId) { return; }
-        this.viewLayout.removeViewSection(leafId);
-        // We need to manually update the layout. Main layout editor doesn't care about missing sections.
-        // but we can't afford that. Without removing it, user can add another section that will be collapsed
-        // from the start, as the id will be the same as the one we just removed.
-        const currentSpec = viewLayout.viewModel.layoutSpecObj();
-        const validSections = new Set(viewLayout.viewModel.viewSections.peek().peek().map(vs => vs.id.peek()));
-        validSections.delete(leafId);
-        currentSpec.collapsed = currentSpec.collapsed
-          ?.filter(x => typeof x.leaf === 'number' && validSections.has(x.leaf));
-        viewLayout.saveLayoutSpec(currentSpec);
+
+        viewLayout.docModel.docData.bundleActions('removing section', async () => {
+          if (!await this.viewLayout.removeViewSection(leafId)) {
+            return;
+          }
+          // We need to manually update the layout. Main layout editor doesn't care about missing sections.
+          // but we can't afford that. Without removing it, user can add another section that will be collapsed
+          // from the start, as the id will be the same as the one we just removed.
+          const currentSpec = viewLayout.viewModel.layoutSpecObj();
+          const validSections = new Set(viewLayout.viewModel.viewSections.peek().peek().map(vs => vs.id.peek()));
+          validSections.delete(leafId);
+          currentSpec.collapsed = currentSpec.collapsed
+            ?.filter(x => typeof x.leaf === 'number' && validSections.has(x.leaf));
+          await viewLayout.saveLayoutSpec(currentSpec);
+        }).catch(reportError);
       }
     };
     this.autoDispose(commands.createGroup(commandGroup, this, true));
@@ -843,7 +848,7 @@ class ExternalLeaf extends Disposable implements Dropped {
         // and the section won't be created on time.
         this.model.viewLayout.layoutEditor.triggerUserEditStop();
         // Manually save the layout.
-        this.model.viewLayout.saveLayoutSpec();
+        this.model.viewLayout.saveLayoutSpec().catch(reportError);
       }
     }));
 
diff --git a/app/client/components/ViewLayout.ts b/app/client/components/ViewLayout.ts
index f017f992..5ba495bd 100644
--- a/app/client/components/ViewLayout.ts
+++ b/app/client/components/ViewLayout.ts
@@ -20,7 +20,12 @@ import {reportError} from 'app/client/models/errors';
 import {getTelemetryWidgetTypeFromVS} from 'app/client/ui/widgetTypesMap';
 import {isNarrowScreen, mediaSmall, testId, theme} from 'app/client/ui2018/cssVars';
 import {icon} from 'app/client/ui2018/icons';
+import {ISaveModalOptions, saveModal} from 'app/client/ui2018/modals';
 import {DisposableWithEvents} from 'app/common/DisposableWithEvents';
+import {makeT} from 'app/client/lib/localization';
+import {urlState} from 'app/client/models/gristUrlState';
+import {cssRadioCheckboxOptions, radioCheckboxOption} from 'app/client/ui2018/checkbox';
+import {cssLink} from 'app/client/ui2018/links';
 import {mod} from 'app/common/gutil';
 import {
   Computed,
@@ -39,6 +44,8 @@ import * as ko from 'knockout';
 import debounce from 'lodash/debounce';
 import * as _ from 'underscore';
 
+const t = makeT('ViewLayout');
+
 // tslint:disable:no-console
 
 const viewSectionTypes: {[key: string]: any} = {
@@ -125,7 +132,7 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent {
     this.listenTo(this.layout, 'layoutUserEditStop', () => {
       this.isResizing.set(false);
       this.layoutSaveDelay.schedule(1000, () => {
-        this.saveLayoutSpec();
+        this.saveLayoutSpec().catch(reportError);
       });
     });
 
@@ -187,7 +194,7 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent {
     }));
 
     const commandGroup = {
-      deleteSection: () => { this.removeViewSection(this.viewModel.activeSectionId()); },
+      deleteSection: () => { this.removeViewSection(this.viewModel.activeSectionId()).catch(reportError); },
       nextSection: () => { this._otherSection(+1); },
       prevSection: () => { this._otherSection(-1); },
       printSection: () => { printViewSection(this.layout, this.viewModel.activeSection()).catch(reportError); },
@@ -265,31 +272,83 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent {
     this._savePending.set(false);
     // Cancel the automatic delay.
     this.layoutSaveDelay.cancel();
-    if (!this.layout) { return; }
+    if (!this.layout) { return Promise.resolve(); }
     // Only save layout changes when the document isn't read-only.
     if (!this.gristDoc.isReadonly.get()) {
       if (!specs) {
         specs = this.layout.getLayoutSpec();
         specs.collapsed = this.viewModel.activeCollapsedSections.peek().map((leaf)=> ({leaf}));
       }
-      this.viewModel.layoutSpecObj.setAndSave(specs).catch(reportError);
+      return this.viewModel.layoutSpecObj.setAndSave(specs).catch(reportError);
     }
     this._onResize();
+    return Promise.resolve();
   }
 
-  // Removes a view section from the current view. Should only be called if there is
-  // more than one viewsection in the view.
-  public removeViewSection(viewSectionRowId: number) {
+  /**
+   * Removes a view section from the current view. Should only be called if there is more than
+   * one viewsection in the view.
+   * @returns A promise that resolves with true when the view section is removed. If user was
+   * prompted and decided to cancel, the promise resolves with false.
+   */
+  public async removeViewSection(viewSectionRowId: number) {
     this.maximized.set(null);
     const viewSection = this.viewModel.viewSections().all().find(s => s.getRowId() === viewSectionRowId);
     if (!viewSection) {
       throw new Error(`Section not found: ${viewSectionRowId}`);
     }
+    const tableId = viewSection.table.peek().tableId.peek();
 
-    const widgetType = getTelemetryWidgetTypeFromVS(viewSection);
-    logTelemetryEvent('deletedWidget', {full: {docIdDigest: this.gristDoc.docId(), widgetType}});
+    // Check if this is a UserTable (not summary) and if so, if it is available on any other page
+    // we have access to (or even on this page but in different widget). If yes, then we are safe
+    // to remove it, otherwise we need to warn the user.
 
-    this.gristDoc.docData.sendAction(['RemoveViewSection', viewSectionRowId]).catch(reportError);
+    const logTelemetry = () => {
+      const widgetType = getTelemetryWidgetTypeFromVS(viewSection);
+      logTelemetryEvent('deletedWidget', {full: {docIdDigest: this.gristDoc.docId(), widgetType}});
+    };
+
+    const isUserTable = () => viewSection.table.peek().isSummary.peek() === false;
+
+    const notInAnyOtherSection = () => {
+      // Get all viewSection we have access to, and check if the table is used in any of them.
+      const others = this.gristDoc.docModel.viewSections.rowModels
+                      .filter(vs => !vs.isDisposed())
+                      .filter(vs => vs.id.peek() !== viewSectionRowId)
+                      .filter(vs => vs.isRaw.peek() === false)
+                      .filter(vs => vs.isRecordCard.peek() === false)
+                      .filter(vs => vs.tableId.peek() === viewSection.tableId.peek());
+      return others.length === 0;
+    };
+
+    const REMOVED = true, IGNORED = false;
+
+    const possibleActions = {
+      [DELETE_WIDGET]: async () => {
+        logTelemetry();
+        await this.gristDoc.docData.sendAction(['RemoveViewSection', viewSectionRowId]);
+        return REMOVED;
+      },
+      [DELETE_DATA]: async () => {
+        logTelemetry();
+        await this.gristDoc.docData.sendActions([
+          ['RemoveViewSection', viewSectionRowId],
+          ['RemoveTable', tableId],
+        ]);
+        return REMOVED;
+      },
+      [CANCEL]: async () => IGNORED,
+    };
+
+    const tableName = () => viewSection.table.peek().tableNameDef.peek();
+
+    const needPrompt = isUserTable() && notInAnyOtherSection();
+
+    const decision = needPrompt
+      ? widgetRemovalPrompt(tableName())
+      : Promise.resolve(DELETE_WIDGET as PromptAction);
+
+    return possibleActions[await decision]();
   }
 
   public rebuildLayout(layoutSpec: BoxSpec) {
@@ -417,6 +476,47 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent {
   }
 }
 
+const DELETE_WIDGET = 'deleteOnlyWidget';
+const DELETE_DATA = 'deleteDataAndWidget';
+const CANCEL = 'cancel';
+type PromptAction = typeof DELETE_WIDGET | typeof DELETE_DATA | typeof CANCEL;
+
+function widgetRemovalPrompt(tableName: string): Promise<PromptAction> {
+  return new Promise<PromptAction>((resolve) => {
+    saveModal((ctl, owner): ISaveModalOptions => {
+      const selected = Observable.create<PromptAction | ''>(owner, '');
+      const saveDisabled = Computed.create(owner, use => use(selected) === '');
+      const saveFunc = async () => selected.get() && resolve(selected.get() as PromptAction);
+      owner.onDispose(() => resolve(CANCEL));
+      return {
+        title: t('Table {{tableName}} will no longer be visible', { tableName }),
+        body: dom('div',
+          testId('removePopup'),
+          cssRadioCheckboxOptions(
+            radioCheckboxOption(selected, DELETE_DATA, t("Delete data and this widget.")),
+            radioCheckboxOption(selected, DELETE_WIDGET,
+              t(
+                `Keep data and delete widget. Table will remain available in {{rawDataLink}}`,
+                {
+                  rawDataLink: cssLink(
+                    t('raw data page'),
+                    urlState().setHref({docPage: 'data'}),
+                    {target: '_blank'},
+                  )
+                }
+              )
+            ),
+          ),
+        ),
+        saveDisabled,
+        saveLabel: t("Delete"),
+        saveFunc,
+        width: 'fixed-wide',
+      };
+    });
+  });
+}
+
 const cssLayoutBox = styled('div', `
   @media screen and ${mediaSmall} {
     &-active, &-inactive {
diff --git a/app/client/models/entities/TableRec.ts b/app/client/models/entities/TableRec.ts
index 4cb2297b..1aa650d3 100644
--- a/app/client/models/entities/TableRec.ts
+++ b/app/client/models/entities/TableRec.ts
@@ -39,6 +39,7 @@ export interface TableRec extends IRowModel<"_grist_Tables"> {
   // If user can select this table in various places.
   // Note: Some hidden tables can still be visible on RawData view.
   isHidden: ko.Computed<boolean>;
+  isSummary: ko.Computed<boolean>;
 
   tableColor: string;
   disableAddRemoveRows: ko.Computed<boolean>;
@@ -68,6 +69,8 @@ export function createTableRec(this: TableRec, docModel: DocModel): void {
   this.primaryTableId = ko.pureComputed(() =>
     this.summarySourceTable() ? this.summarySource().tableId() : this.tableId());
 
+  this.isSummary = this.autoDispose(ko.pureComputed(() => Boolean(this.summarySourceTable())));
+
   this.groupByColumns = ko.pureComputed(() => this.columns().all().filter(c => c.summarySourceCol()));
 
   this.groupDesc = ko.pureComputed(() => {
diff --git a/app/client/models/entities/ViewSectionRec.ts b/app/client/models/entities/ViewSectionRec.ts
index efcf45a6..181fcd86 100644
--- a/app/client/models/entities/ViewSectionRec.ts
+++ b/app/client/models/entities/ViewSectionRec.ts
@@ -93,9 +93,12 @@ export interface ViewSectionRec extends IRowModel<"_grist_Views_section">, RuleO
   // in which case the UI prevents various things like hiding columns or changing the widget type.
   isRaw: ko.Computed<boolean>;
 
-  tableRecordCard: ko.Computed<ViewSectionRec>
+  /** Is this table card viewsection (the one available after pressing spacebar) */
   isRecordCard: ko.Computed<boolean>;
 
+  /** Card record viewSection for associated table (might be the same section) */
+  tableRecordCard: ko.Computed<ViewSectionRec>;
+
   /** True if this section is disabled. Currently only used by Record Card sections. */
   disabled: modelUtil.KoSaveableObservable<boolean>;
 
diff --git a/test/nbrowser/SelectBySummary.ts b/test/nbrowser/SelectBySummary.ts
index a2bd492e..77bd4794 100644
--- a/test/nbrowser/SelectBySummary.ts
+++ b/test/nbrowser/SelectBySummary.ts
@@ -178,9 +178,7 @@ describe('SelectBySummary', function() {
 
   it('should filter a summary table selected by a less detailed summary table', async function() {
     // Delete the Table1 widget so that we can hide the table in ACL without hiding the whole page.
-    const menu = await gu.openSectionMenu('viewLayout', 'TABLE1');
-    await menu.findContent('.test-cmd-name', 'Delete widget').click();
-    await gu.waitForServer();
+    await gu.deleteWidget('TABLE1');
 
     // Open the ACL UI
     await driver.find('.test-tools-access-rules').click();
diff --git a/test/nbrowser/ViewLayoutCollapse.ts b/test/nbrowser/ViewLayoutCollapse.ts
index 9086e921..3c522373 100644
--- a/test/nbrowser/ViewLayoutCollapse.ts
+++ b/test/nbrowser/ViewLayoutCollapse.ts
@@ -761,6 +761,96 @@ describe("ViewLayoutCollapse", function() {
     await gu.checkForErrors();
   });
 
+  it('should prompt when last section is removed from tray', async () => {
+    const revert = await gu.begin();
+
+    // Add brand new table and collapse it.
+    await gu.addNewSection('Table', 'New Table', {tableName: 'ToCollapse'});
+    await collapseByMenu('ToCollapse');
+
+    // Now try to remove it, we should see prompt.
+    await openCollapsedSectionMenu('ToCollapse');
+    await driver.find('.test-section-delete').click();
+    assert.match(
+      await driver.find('.test-modal-title').getText(),
+      /Table ToCollapse will no longer be visible/
+    );
+
+    // Select first option, to delete both table and widget.
+    await driver.find('.test-option-deleteDataAndWidget').click();
+    await driver.find('.test-modal-confirm').click();
+    await gu.waitForServer();
+
+    // Make sure it is removed.
+    assert.deepEqual(await collapsedSectionTitles(), []);
+    assert.deepEqual(await visibleTables(), ['Companies', 'Investments']);
+    await gu.sendKeys(Key.ESCAPE);
+
+    // Single undo should add it back.
+    await gu.undo();
+    assert.deepEqual(await collapsedSectionTitles(), ['TOCOLLAPSE']);
+    assert.deepEqual(await visibleTables(), ['Companies', 'Investments', 'ToCollapse']);
+
+    // Now do the same but, keep data.
+    await openCollapsedSectionMenu('ToCollapse');
+    await driver.find('.test-section-delete').click();
+    await driver.findWait('.test-modal-dialog', 100);
+    await driver.find('.test-option-deleteOnlyWidget').click();
+    await driver.find('.test-modal-confirm').click();
+    await gu.waitForServer();
+
+    // Make sure it is removed.
+    assert.deepEqual(await collapsedSectionTitles(), []);
+    assert.deepEqual(await visibleTables(), ['Companies', 'Investments', 'ToCollapse']);
+
+    // Test single undo.
+    await gu.undo();
+    assert.deepEqual(await collapsedSectionTitles(), ['TOCOLLAPSE']);
+    assert.deepEqual(await visibleTables(), ['Companies', 'Investments', 'ToCollapse']);
+
+    // Uncollapse it, and do the same with normal section.
+    await addToMainByMenu('ToCollapse');
+
+    // Now try to remove it, we should see prompt.
+    assert.include(
+      await driver.findAll('.test-viewsection-title', e => e.getText()), 'TOCOLLAPSE');
+
+    await gu.openSectionMenu('viewLayout', 'ToCollapse');
+    await driver.find('.test-section-delete').click();
+    await driver.findWait('.test-modal-dialog', 100);
+    await driver.find('.test-option-deleteOnlyWidget').click();
+    await driver.find('.test-modal-confirm').click();
+    await gu.waitForServer();
+    assert.notInclude(
+      await driver.findAll('.test-viewsection-title', e => e.getText()), 'TOCOLLAPSE');
+    assert.deepEqual(await visibleTables(), ['Companies', 'Investments', 'ToCollapse']);
+    // Test undo.
+    await gu.undo();
+    assert.include(
+      await driver.findAll('.test-viewsection-title', e => e.getText()), 'TOCOLLAPSE');
+
+    // Do the same but delete data and widget.
+    await gu.openSectionMenu('viewLayout', 'ToCollapse');
+    await driver.find('.test-section-delete').click();
+    await driver.findWait('.test-modal-dialog', 100);
+    await driver.find('.test-option-deleteDataAndWidget').click();
+    await driver.find('.test-modal-confirm').click();
+    await gu.waitForServer();
+
+    // Make sure it is removed.
+    assert.notInclude(
+      await driver.findAll('.test-viewsection-title', e => e.getText()), 'TOCOLLAPSE');
+    assert.deepEqual(await visibleTables(), ['Companies', 'Investments']);
+
+    // Test undo.
+    await gu.undo();
+    assert.include(
+      await driver.findAll('.test-viewsection-title', e => e.getText()), 'TOCOLLAPSE');
+    assert.deepEqual(await visibleTables(), ['Companies', 'Investments', 'ToCollapse']);
+
+    await revert();
+  });
+
   it("should switch active section when collapsed", async () => {
     const revert = await gu.begin();
     await gu.selectSectionByTitle(gu.exactMatch(COMPANIES));
@@ -989,3 +1079,11 @@ async function waitForSave() {
     await gu.waitForServer();
   }, 3000);
 }
+
+async function visibleTables() {
+  await driver.findWait('.test-dp-add-new', 2000).doClick();
+  await driver.find('.test-dp-add-new-page').doClick();
+  const titles = await driver.findAll('.test-wselect-table', e => e.getText());
+  await gu.sendKeys(Key.ESCAPE);
+  return titles.map(x => x.trim()).filter(Boolean).filter(x => x !== 'New Table');
+}
diff --git a/test/nbrowser/Views.ntest.js b/test/nbrowser/Views.ntest.js
index 9b8e2e8a..5da3560d 100644
--- a/test/nbrowser/Views.ntest.js
+++ b/test/nbrowser/Views.ntest.js
@@ -113,8 +113,7 @@ describe('Views.ntest', function() {
     await gu.waitForServer();
     await gu.actions.viewSection('TABLE4').selectSection();
     // Delete the section
-    await gu.actions.viewSection('TABLE4').selectMenuOption('viewLayout', 'Delete widget');
-    await gu.waitForServer();
+    await gu.deleteWidget('TABLE4');
     // Assert that the default section (Table1 record) is now active.
     assert.equal(await $('.active_section > .viewsection_title').text(), 'TABLE1');
     // Assert that focus is returned to the deleted section on undo.
diff --git a/test/nbrowser/gristUtils.ts b/test/nbrowser/gristUtils.ts
index fe36fbe0..6fc26dd6 100644
--- a/test/nbrowser/gristUtils.ts
+++ b/test/nbrowser/gristUtils.ts
@@ -3791,6 +3791,19 @@ export async function waitForAccessDenied() {
   });
 }
 
+/**
+ * Deletes a widget by title. Optionally confirms deletion only for the widget without the data.
+ */
+export async function deleteWidget(title: string) {
+  const menu = await openSectionMenu('viewLayout', title);
+  await menu.findContent('.test-cmd-name', 'Delete widget').click();
+  if (await driver.findWait('.test-option-deleteOnlyWidget', 100).isPresent()) {
+    await driver.find('.test-option-deleteOnlyWidget').click();
+    await driver.find('.test-modal-confirm').click();
+  }
+  await waitForServer();
+}
+
 } // end of namespace gristUtils
 
 stackWrapOwnMethods(gristUtils);

From c9f9b70b67e0f44b467b5206ca5d2301988bad51 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= <jordigh@octave.org>
Date: Thu, 25 Jul 2024 23:01:55 -0400
Subject: [PATCH 080/145] apiconsole: allow uploads in console

By adding an XHR to "Try it out" requests, we can make non-JSON
requests pass a CORS check.
---
 app/client/apiconsole.ts | 19 +++++++++++++++++++
 1 file changed, 19 insertions(+)

diff --git a/app/client/apiconsole.ts b/app/client/apiconsole.ts
index 0a888173..98bb59c0 100644
--- a/app/client/apiconsole.ts
+++ b/app/client/apiconsole.ts
@@ -293,6 +293,25 @@ function initialize(appModel: AppModel) {
 
 function requestInterceptor(request: SwaggerUI.Request) {
   delete request.headers.Authorization;
+  const url = new URL(request.url);
+  // Swagger will use this request interceptor for several kinds of
+  // requests, such as requesting the API YAML spec from Github:
+  //
+  //      Function to intercept remote definition, "Try it out",
+  //      and OAuth 2.0 requests.
+  //
+  //    https://swagger.io/docs/open-source-tools/swagger-ui/usage/configuration/
+  //
+  // We want to ensure that only "Try it out" requests have XHR, so
+  // that they pass a same origin request, even if they're not GET,
+  // HEAD, or OPTIONS. "Try it out" requests are the requests to the
+  // same origin.
+  if (url.origin === window.origin) {
+    // Without this header, unauthenticated multipart POST requests
+    // (i.e. file uploads) would fail in the API console. We want those
+    // requests to succeed.
+    request.headers['X-Requested-With'] = 'XMLHttpRequest';
+  }
   return request;
 }
 

From 9b3ae08ece7c200286efe948c90d3d357c3042e3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= <jordigh@octave.org>
Date: Fri, 26 Jul 2024 12:31:43 -0400
Subject: [PATCH 081/145] create: hard-code the default session secret even
 more

The problem here is that making it this optional meant that it wasn't
supplied by [the enterprise creation
function](https://github.com/gristlabs/grist-ee/blob/fb22d94878a539ec9f1085fa9ac12936ccb68dca/ext/app/server/lib/create.ts#L10).
This resulted in an odd situation where the secret was required for
the enterprise edition, even though it offers no additional security.
Without this key, the enterprise code crashes.

The requirement to supply a secret key would make a Grist instance
crash if you start in normal mode but switch to enterprise, as the
enterprise creator does not supply a default secret key.
---
 app/server/lib/BootProbes.ts  | 2 +-
 app/server/lib/ICreate.ts     | 9 ++++-----
 app/server/lib/coreCreator.ts | 6 ------
 3 files changed, 5 insertions(+), 12 deletions(-)

diff --git a/app/server/lib/BootProbes.ts b/app/server/lib/BootProbes.ts
index adef4811..36c3786c 100644
--- a/app/server/lib/BootProbes.ts
+++ b/app/server/lib/BootProbes.ts
@@ -6,7 +6,7 @@ import { GristServer } from 'app/server/lib/GristServer';
 import * as express from 'express';
 import WS from 'ws';
 import fetch from 'node-fetch';
-import { DEFAULT_SESSION_SECRET } from 'app/server/lib/coreCreator';
+import { DEFAULT_SESSION_SECRET } from 'app/server/lib/ICreate';
 
 /**
  * Self-diagnostics useful when installing Grist.
diff --git a/app/server/lib/ICreate.ts b/app/server/lib/ICreate.ts
index 4b4d66ee..bf6fa69d 100644
--- a/app/server/lib/ICreate.ts
+++ b/app/server/lib/ICreate.ts
@@ -13,6 +13,9 @@ import {createSandbox, SpawnFn} from 'app/server/lib/NSandbox';
 import {SqliteVariant} from 'app/server/lib/SqliteCommon';
 import {ITelemetry} from 'app/server/lib/Telemetry';
 
+export const DEFAULT_SESSION_SECRET =
+  'Phoo2ag1jaiz6Moo2Iese2xoaphahbai3oNg7diemohlah0ohtae9iengafieS2Hae7quungoCi9iaPh';
+
 export interface ICreate {
 
   Billing(dbManager: HomeDBManager, gristConfig: GristServer): IBilling;
@@ -116,11 +119,7 @@ export function makeSimpleCreator(opts: {
       return createSandbox(opts.sandboxFlavor || 'unsandboxed', options);
     },
     sessionSecret() {
-      const secret = process.env.GRIST_SESSION_SECRET || sessionSecret;
-      if (!secret) {
-        throw new Error('need GRIST_SESSION_SECRET');
-      }
-      return secret;
+      return process.env.GRIST_SESSION_SECRET || sessionSecret || DEFAULT_SESSION_SECRET;
     },
     async configure() {
       for (const s of storage || []) {
diff --git a/app/server/lib/coreCreator.ts b/app/server/lib/coreCreator.ts
index 477c970b..2eda4e9f 100644
--- a/app/server/lib/coreCreator.ts
+++ b/app/server/lib/coreCreator.ts
@@ -3,14 +3,8 @@ import { checkMinIOBucket, checkMinIOExternalStorage,
 import { makeSimpleCreator } from 'app/server/lib/ICreate';
 import { Telemetry } from 'app/server/lib/Telemetry';
 
-export const DEFAULT_SESSION_SECRET =
-  'Phoo2ag1jaiz6Moo2Iese2xoaphahbai3oNg7diemohlah0ohtae9iengafieS2Hae7quungoCi9iaPh';
-
 export const makeCoreCreator = () => makeSimpleCreator({
   deploymentType: 'core',
-  // This can and should be overridden by GRIST_SESSION_SECRET
-  // (or generated randomly per install, like grist-omnibus does).
-  sessionSecret: DEFAULT_SESSION_SECRET,
   storage: [
     {
       name: 'minio',

From 09871480ba576317d1af0188e01caafb9474fbe7 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= <jordigh@octave.org>
Date: Fri, 26 Jul 2024 12:55:16 -0400
Subject: [PATCH 082/145] create: add a short docstring for `makeSimpleCreator`

---
 app/server/lib/ICreate.ts | 9 +++++++++
 1 file changed, 9 insertions(+)

diff --git a/app/server/lib/ICreate.ts b/app/server/lib/ICreate.ts
index bf6fa69d..5ebdc344 100644
--- a/app/server/lib/ICreate.ts
+++ b/app/server/lib/ICreate.ts
@@ -75,6 +75,15 @@ export interface ICreateTelemetryOptions {
   create(dbManager: HomeDBManager, gristConfig: GristServer): ITelemetry|undefined;
 }
 
+/**
+ * This function returns a `create` object that defines various core
+ * aspects of a Grist installation, such as what kind of billing or
+ * sandbox to use, if any.
+ *
+ * The intended use of this function is to initialise Grist with
+ * different settings and providers, to facilitate different editions
+ * such as standard, enterprise or cloud-hosted.
+ */
 export function makeSimpleCreator(opts: {
   deploymentType: GristDeploymentType,
   sessionSecret?: string,

From fea7c0b53665e7635a4358fb18d03dac91d7c8c7 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= <jordigh@octave.org>
Date: Fri, 26 Jul 2024 13:35:26 -0400
Subject: [PATCH 083/145] create: add a comment explaining the session secret
 situation

---
 app/server/lib/ICreate.ts | 20 ++++++++++++++++++++
 1 file changed, 20 insertions(+)

diff --git a/app/server/lib/ICreate.ts b/app/server/lib/ICreate.ts
index 5ebdc344..6e6ed5a7 100644
--- a/app/server/lib/ICreate.ts
+++ b/app/server/lib/ICreate.ts
@@ -13,6 +13,26 @@ import {createSandbox, SpawnFn} from 'app/server/lib/NSandbox';
 import {SqliteVariant} from 'app/server/lib/SqliteCommon';
 import {ITelemetry} from 'app/server/lib/Telemetry';
 
+// In the past, the session secret was used as an additional
+// protection passed on to expressjs-session for security when
+// generating session IDs, in order to make them less guessable.
+// Quoting the upstream documentation,
+//
+//     Using a secret that cannot be guessed will reduce the ability
+//     to hijack a session to only guessing the session ID (as
+//     determined by the genid option).
+//
+//   https://expressjs.com/en/resources/middleware/session.html
+//
+// However, since this change,
+//
+//   https://github.com/gristlabs/grist-core/commit/24ce54b586e20a260376a9e3d5b6774e3fa2b8b8#diff-d34f5357f09d96e1c2ba63495da16aad7bc4c01e7925ab1e96946eacd1edb094R121-R124
+//
+// session IDs are now completely randomly generated in a cryptographically
+// secure way, so there is no danger of session IDs being guessable.
+// This makes the value of the session secret less important. The only
+// concern is that changing the secret will invalidate existing
+// sessions and force users to log in again.
 export const DEFAULT_SESSION_SECRET =
   'Phoo2ag1jaiz6Moo2Iese2xoaphahbai3oNg7diemohlah0ohtae9iengafieS2Hae7quungoCi9iaPh';
 

From db26f3be6a48a54f4bce2b3a6276f6061d5e4c7a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= <jordigh@octave.org>
Date: Mon, 29 Jul 2024 09:55:16 -0400
Subject: [PATCH 084/145] documentation: fix data types table formatting

---
 documentation/grist-data-format.md | 36 +++++++++++++++++-------------
 1 file changed, 20 insertions(+), 16 deletions(-)

diff --git a/documentation/grist-data-format.md b/documentation/grist-data-format.md
index fd010a61..14e55797 100644
--- a/documentation/grist-data-format.md
+++ b/documentation/grist-data-format.md
@@ -124,28 +124,32 @@ Note that this combination of rules allows tables and column names to be valid i
 
 ## Value Types
 
+> [!WARNING]
+> This section is out of date.
+
 The format supports a number of data types. Some types have a short representation (e.g. `Numeric` as a JSON `number`, and `Text` as a JSON `string`), but all types have an explicit representation as well.
 
 The explicit representation of a value is an array `[typeCode, args...]`. The first member of the array is a string code that defines the type of the value. The rest of the elements are arguments used to construct the actual value.
 
 The following table lists currently supported types and their short and explicit representations.
 
-| **Type Name** | **Short Repr** | **[Type Code, Args...]** | **Description** |
-| `Numeric` | `number`* | `['n',number]` | double-precision floating point number |
-| `Text` | `string`* | `['s',string]` | Unicode string |
-| `Bool` | `bool`* | `['b',bool]` | Boolean value (true or false) |
-| `Null` | `null`* | `null` | Null value (no special explicit representation) |
-| `Int` | `number` | `['i',number]` | 32-bit integer |
-| `Date` | `number` | `['d',number]` | Calendar date, represented as seconds since Epoch to 00:00 UTC on that date. |
-| `DateTime` | `number` | `['D',number]` | Instance in time, represented as seconds since Epoch |
-| `Reference` | `number` | `['R',number]`  | Identifier of a record in a table. |
-| `ReferenceList` | | `['L',number,...]` | List of record identifiers |
-| `Choice` | `string` | `['C',string]` | Unicode string selected from a list of choices. |
-| `PositionNumber` | `number` | `['P',number]` | a double used to order records relative to each other. |
-| `Image` | | `['I',string]` | Binary data representing an image, encoded as base64 |
-| `List` | | `['l',values,...]` | List of values of any type. |
-| `JSON` | | `['J',object]` | JSON-serializable object |
-| `Error` | | `['E',string,string?,value?]` | Exception, with first argument exception type, second an optional message, and optionally a third containing additional info. |
+| **Type Name**    | **Short Repr** | **[Type Code, Args...]**      | **Description**                                                                                                               |
+|------------------|----------------|-------------------------------|-------------------------------------------------------------------------------------------------------------------------------|
+| `Numeric`        | `number`*      | `['n',number]`                | double-precision floating point number                                                                                        |
+| `Text`           | `string`*      | `['s',string]`                | Unicode string                                                                                                                |
+| `Bool`           | `bool`*        | `['b',bool]`                  | Boolean value (true or false)                                                                                                 |
+| `Null`           | `null`*        | `null`                        | Null value (no special explicit representation)                                                                               |
+| `Int`            | `number`       | `['i',number]`                | 32-bit integer                                                                                                                |
+| `Date`           | `number`       | `['d',number]`                | Calendar date, represented as seconds since Epoch to 00:00 UTC on that date.                                                  |
+| `DateTime`       | `number`       | `['D',number]`                | Instance in time, represented as seconds since Epoch                                                                          |
+| `Reference`      | `number`       | `['R',number]`                | Identifier of a record in a table.                                                                                            |
+| `ReferenceList`  |                | `['L',number,...]`            | List of record identifiers                                                                                                    |
+| `Choice`         | `string`       | `['C',string]`                | Unicode string selected from a list of choices.                                                                               |
+| `PositionNumber` | `number`       | `['P',number]`                | a double used to order records relative to each other.                                                                        |
+| `Image`          |                | `['I',string]`                | Binary data representing an image, encoded as base64                                                                          |
+| `List`           |                | `['l',values,...]`            | List of values of any type.                                                                                                   |
+| `JSON`           |                | `['J',object]`                | JSON-serializable object                                                                                                      |
+| `Error`          |                | `['E',string,string?,value?]` | Exception, with first argument exception type, second an optional message, and optionally a third containing additional info. |
 
 An important goal is to represent data efficiently in the common case. When a value matches the column's type, the short representation is used. For example, in a Numeric column, a Numeric value is represented as a `number`, and in a Date column, a Date value is represented as a `number`.
 

From bb0213ecbe413e15c75b9186ac5a7fdfa3c1b9b5 Mon Sep 17 00:00:00 2001
From: Dmitry S <dsagal+git@gmail.com>
Date: Mon, 29 Jul 2024 11:45:45 -0700
Subject: [PATCH 085/145] (core) Fix regression that caused Date/DateTime
 series to be treated as categorical data

Test Plan: Tested manually with a Date and DateTime column type.

Reviewers: paulfitz

Reviewed By: paulfitz

Subscribers: paulfitz

Differential Revision: https://phab.getgrist.com/D4305
---
 app/client/components/ChartView.ts | 5 ++++-
 1 file changed, 4 insertions(+), 1 deletion(-)

diff --git a/app/client/components/ChartView.ts b/app/client/components/ChartView.ts
index 37ac7574..51e36b77 100644
--- a/app/client/components/ChartView.ts
+++ b/app/client/components/ChartView.ts
@@ -78,6 +78,9 @@ export function isNumericLike(col: ColumnRec, use: UseCB = unwrap) {
   return ['Numeric', 'Int', 'Any'].includes(colType);
 }
 
+function isCategoryType(pureType: string): boolean {
+  return !['Numeric', 'Int', 'Any', 'Date', 'DateTime'].includes(pureType);
+}
 
 interface ChartOptions {
   multiseries?: boolean;
@@ -1125,7 +1128,7 @@ export const chartTypes: {[name: string]: ChartFunc} = {
   bar(series: Series[], options: ChartOptions): PlotData {
     // If the X axis is not from numerical column, treat it as category.
     const data = basicPlot(series, options, {type: 'bar'});
-    const useCategory = series[0]?.pureType && !['Numeric', 'Int', 'Any'].includes(series[0].pureType);
+    const useCategory = series[0]?.pureType && isCategoryType(series[0].pureType);
     const xaxisName = options.orientation === 'h' ? 'yaxis' : 'xaxis';
     if (useCategory && data.layout && data.layout[xaxisName]) {
       const axisConfig = data.layout[xaxisName]!;

From bceecaf1ad383c5ffdefff33d8dca1beeaba4d38 Mon Sep 17 00:00:00 2001
From: xabirequejo <xabi.rn@gmail.com>
Date: Mon, 29 Jul 2024 07:40:12 +0000
Subject: [PATCH 086/145] Translated using Weblate (Basque)

Currently translated at 99.8% (1339 of 1341 strings)

Translation: Grist/client
Translate-URL: https://hosted.weblate.org/projects/grist/client/eu/
---
 static/locales/eu.client.json | 244 +++++++++++++++++-----------------
 1 file changed, 122 insertions(+), 122 deletions(-)

diff --git a/static/locales/eu.client.json b/static/locales/eu.client.json
index 77bd36c0..f849bd0f 100644
--- a/static/locales/eu.client.json
+++ b/static/locales/eu.client.json
@@ -37,7 +37,7 @@
         "User Attributes": "Erabiltzailearen atributuak",
         "Seed rules": "-",
         "Add Table-wide Rule": "Gehitu taula osorako araua",
-        "This default should be changed if editors' access is to be limited. ": "Akats hori aldatu beharko litzateke editoreen sarbidea mugatu nahi bada. ",
+        "This default should be changed if editors' access is to be limited. ": "Editoreen sarbidea mugatu nahi bada, defektuzko araua aldatu beharko litzateke. ",
         "Remove {{- tableId }} rules": "Kendu {{- tableId}} arauak",
         "Remove {{- name }} user attribute": "Kendu {{- name}} erabiltzailearen atributua",
         "Remove column {{- colId }} from {{- tableId }} rules": "Kendu {{- colId }} zutabea {{- tableId }} arauetatik",
@@ -85,7 +85,7 @@
     "ViewAsDropdown": {
         "View As": "Ikusi honela",
         "Users from table": "Taulako erabiltzaileak",
-        "Example Users": "Erabiltzaile-eredua"
+        "Example Users": "Probako erabiltzaileak"
     },
     "ActionLog": {
         "All tables": "Taula guztiak",
@@ -103,7 +103,7 @@
         "Remove": "Kendu",
         "Remove API Key": "Kendu API gakoa",
         "This API key can be used to access this account anonymously via the API.": "API gako hau erabiliz kontu honetara modu anonimoan sartzea dago.",
-        "By generating an API key, you will be able to make API calls for your own account.": "API gako bat sortuz gero, zure kontua eskatu ahal izango du.",
+        "By generating an API key, you will be able to make API calls for your own account.": "API gako bat sortuz gero, zure kontuari eskaerak egin ahal izango dizkiozu.",
         "This API key can be used to access your account via the API. Don’t share your API key with anyone.": "API gako hau erabiliz zure kontuan sartzea dago. Ez partekatu zure API gakoa inorekin.",
         "You're about to delete an API key. This will cause all future requests using this API key to be rejected. Do you still want to delete?": "API gako bat ezabatzear zaude. Horrek etorkizuneko eskaera guztiak baztertuko ditu. Ziur ezabatu nahi duzula?"
     },
@@ -120,7 +120,7 @@
         "Manage Team": "Kudeatu Taldea",
         "Home Page": "Hasierako orria",
         "Billing Account": "Fakturazio-kontua",
-        "Legacy": "Legatua"
+        "Legacy": "Zaharkitua"
     },
     "AppModel": {
         "This team site is suspended. Documents can be read, but not modified.": "Taldearen gunea bertan behera utzi da. Dokumentuak irakur daitezke, baina ez moldatu."
@@ -151,7 +151,7 @@
         "Filter by this value": "Iragazi balio honen arabera"
     },
     "ColorSelect": {
-        "Apply": "Ezarri",
+        "Apply": "Aplikatu",
         "Cancel": "Utzi",
         "Default cell style": "Gelaxken defektuzko estiloa"
     },
@@ -169,9 +169,9 @@
         "End": "Amaitu",
         "Other Values": "Beste balio batzuk",
         "All Except": "Denak, hauek izan ezik",
-        "Other Non-Matching": "Bat ez datozen beste batzuk",
-        "Other Matching": "Bat datozen beste bat",
-        "All Shown": "Guztiak erakusten ari dira",
+        "Other Non-Matching": "Bat ez datorren beste bat",
+        "Other Matching": "Bat datorren beste bat",
+        "All Shown": "Guztiak erakusten",
         "Future Values": "Etorkizuneko balioak"
     },
     "CustomSectionConfig": {
@@ -181,12 +181,12 @@
         "Pick a column": "Hautatu zutabea",
         "Read selected table": "Irakurri hautatutako taula",
         "Widget does not require any permissions.": "Widgetak ez du baimenik behar.",
-        "Clear selection": "Garbitu hautatutakoa",
+        "Clear selection": "Garbitu hautaketa",
         "Pick a {{columnType}} column": "Aukeratu {{columnType}} zutabe bat",
-        "Learn more about custom widgets": "Ikasi gehiago norbere widgeti buruz",
+        "Learn more about custom widgets": "Ikasi gehiago widget pertsonalizatuei buruz",
         "No document access": "Sarbiderik ez dokumentura",
-        "Widget needs to {{read}} the current table.": "Widgetek {{read}} behar du uneko taula.",
-        "Select Custom Widget": "Aukeratu Widget pertsonalizatua",
+        "Widget needs to {{read}} the current table.": "Widgetak uneko taula {{read}} behar du.",
+        "Select Custom Widget": "Hautatu widget pertsonalizatua",
         "Widget needs {{fullAccess}} to this document.": "Widgetak {{fullAccess}} sarbidea behar du dokumentu honetara.",
         "{{wrongTypeCount}} non-{{columnType}} columns are not shown_one": "{{wrongTypeCount}} ez da {{columnType}} zutabea erakusten",
         "No {{columnType}} columns in table.": "Ez dago {{columnType}} zutaberik taulan.",
@@ -214,7 +214,7 @@
         "Compare to Current": "Alderatu unekoarekin",
         "Compare to Previous": "Alderatu aurrekoarekin",
         "Snapshots are unavailable.": "Argazkiak ez daude erabilgarri.",
-        "Only owners have access to snapshots for documents with access rules.": "Jabeek bakarrik eskura ditzakete sarbide-arauak dituzten dokumentuak.",
+        "Only owners have access to snapshots for documents with access rules.": "Jabeek bakarrik eskura ditzakete sarbide-arauak dituzten dokumentuen argazkiak.",
         "Snapshots": "Argazkiak",
         "Open Snapshot": "Ireki argazkia"
     },
@@ -269,7 +269,7 @@
         "Enter recovery mode": "Sartu berreskuratze moduan",
         "Sorry, access to this document has been denied. [{{error}}]": "Barkatu, dokumentu honetarako sarbidea ukatu da. [{{error}}]",
         "Error accessing document": "Errorea dokumentura sartzean",
-        "You can try reloading the document, or using recovery mode. Recovery mode opens the document to be fully accessible to owners, and inaccessible to others. It also disables formulas. [{{error}}]": "Dokumentua birkargatzen saia zaitezke, edo berreskuratze-modua erabiltzen. Berreskuratzeko moduak dokumentua irekitzen du jabeentzat guztiz irisgarria izateko, eta beste batzuentzat eskuraezina. Formulak ere desgaitzen ditu. [{{error}}]"
+        "You can try reloading the document, or using recovery mode. Recovery mode opens the document to be fully accessible to owners, and inaccessible to others. It also disables formulas. [{{error}}]": "Dokumentua birkargatzen saia zaitezke, edo berreskuratze-modua erabiltzen. Berreskuratze-moduak dokumentua jabeentzat guztiz irisgarria izateko irekitzen du, baina gainerakoentzat eskuraezin egongo da. Formulak ere ezgaitzen ditu. [{{error}}]"
     },
     "DocumentSettings": {
         "Currency:": "Moneta:",
@@ -293,7 +293,7 @@
         "Time Zone": "Ordu-eremua",
         "Cancel": "Utzi",
         "Locale:": "Eskualdeko ezarpenak:",
-        "Engine (experimental {{span}} change at own risk):": "Motorra (esperimentala {{span}} aldatu zure kontu):",
+        "Engine (experimental {{span}} change at own risk):": "Motorra ({{span}} esperimentala, aldatu zure kontu):",
         "Manage Webhooks": "Kudeatu webhookak",
         "Webhooks": "Webhookak",
         "API Console": "API kontsola",
@@ -304,8 +304,8 @@
         "Data Engine": "Datu-motorra",
         "ID for API use": "APIaren erabilerarako Ida",
         "Locale": "Eskualdeko ezarpenak",
-        "Hard reset of data engine": "Datu-motorraren berrezarpena",
-        "Try API calls from the browser": "Saiatu APIren deiak nabigatzailetik",
+        "Hard reset of data engine": "Datu-motorraren berrasiera",
+        "Try API calls from the browser": "Probatu APIaren eskaerak nabigatzailetik",
         "Reload data engine": "Birkargatu datu-motorra",
         "Formula timer": "Formula-kronometroa",
         "Reload data engine?": "Datu-motorra birkargatu?",
@@ -325,7 +325,7 @@
         "Notify other services on doc changes": "Jakinarazi beste zerbitzu batzuei dokumentuak aldatzerakoan",
         "python2 (legacy)": "python2 (legatua)",
         "python3 (recommended)": "python3 (gomendatua)",
-        "Time reload": "Kargatu denbora"
+        "Time reload": "Kronometratu birkarga"
     },
     "DocumentUsage": {
         "Attachments Size": "Eranskinen tamaina",
@@ -347,15 +347,15 @@
         "Lightweight CRM": "CRM arina",
         "Afterschool Program": "Eskolaz kanpoko programa",
         "Investment Research": "Inbertsioen ikerketa",
-        "Check out our related tutorial for how to link data, and create high-productivity layouts.": "Kontsultatu gure tutoriala datuak lotzeko eta produktibitate handiko antolaketak sortzeko.",
+        "Check out our related tutorial for how to link data, and create high-productivity layouts.": "Begiratu gure tutoriala datuak lotzeko eta produktibitate handiko antolaketak sortzeko.",
         "Tutorial: Analyze & Visualize": "Tutoriala: aztertu eta bistaratu",
         "Tutorial: Create a CRM": "Tutoriala: CRM bat sortu",
         "Tutorial: Manage Business Data": "Tutoriala: Negozio-datuak kudeatu",
         "Welcome to the Afterschool Program template": "Ongi etorri Eskolaz kanpoko programa txantiloira",
         "Welcome to the Investment Research template": "Ongi etorri Inbertsioen ikerketa txantiloira",
         "Welcome to the Lightweight CRM template": "Ongi etorri CRM arinaren txantiloira",
-        "Check out our related tutorial for how to model business data, use formulas, and manage complexity.": "Kontsultatu gure tutoriala negozio-datuak modelatzeko, formulak erabiltzeko eta konplexutasuna kudeatzeko.",
-        "Check out our related tutorial to learn how to create summary tables and charts, and to link charts dynamically.": "Kontsultatu gure tutoriala laburpen-taulak eta grafikoak sortzen ikasteko eta grafikoak dinamikoki lotzeko."
+        "Check out our related tutorial for how to model business data, use formulas, and manage complexity.": "Begiratu gure tutoriala negozio-datuak modelatzeko, formulak erabiltzeko eta konplexutasuna kudeatzeko.",
+        "Check out our related tutorial to learn how to create summary tables and charts, and to link charts dynamically.": "Begiratu gure tutoriala laburpen-taulak eta grafikoak sortzen ikasteko eta grafikoak dinamikoki lotzeko."
     },
     "FieldConfig": {
         "Clear and reset": "Garbitu eta berrezarri",
@@ -365,7 +365,7 @@
         "Set formula": "Ezarri formula",
         "DESCRIPTION": "DESKRIBAPENA",
         "Convert column to data": "Bilakatu zutabea datu",
-        "Column options are limited in summary tables.": "Zutabeen aukerak laburpen-tauletan mugatuta daude.",
+        "Column options are limited in summary tables.": "Zutabeen aukerak laburpen-tauletara mugatuta daude.",
         "Data Columns_other": "Datu-zutabeak",
         "Formula Columns_one": "Formula-zutabea",
         "Formula Columns_other": "Formula-zutabeak",
@@ -443,15 +443,15 @@
         "Last Updated At": "Azken eguneraketa",
         "Last Updated By": "Azken eguneratzailea",
         "UUID": "UUIDa",
-        "Timestamp": "Denbora-zigilua",
+        "Timestamp": "Data-zigilua",
         "Add formula column": "Gehitu formula-zutabea",
         "Created at": "Sortze-data",
         "Created by": "Sortzailea",
         "Last updated at": "Azken eguneraketa",
-        "DateTime": "DateTime",
+        "DateTime": "Data eta ordua",
         "Reference": "Erreferentzia",
         "Attachment": "Eranskina",
-        "Integer": "Zbk osoa",
+        "Integer": "Zenbaki osoa",
         "Created At": "Sortze-data",
         "Created By": "Sortzailea",
         "Lookups": "Bilaketak",
@@ -462,7 +462,7 @@
         "No reference columns.": "Ez dago erreferentzia-zutaberik.",
         "Search columns": "Bilaketa-zutabeak",
         "Detect duplicates in...": "Antzeman bikoizketak…",
-        "Add column with type": "Gehitu zutabea tipoarekin",
+        "Add column with type": "Gehitu mota honetako zutabea",
         "Last updated by": "Azken eguneratzailea",
         "Numeric": "Zenbakizkoa",
         "Reference List": "Erreferentzia-zerrenda"
@@ -530,20 +530,20 @@
         "Sign up": "Eman izena",
         "No destination workspace": "Ez dago helmugako lan-eremurik",
         "Original Looks Unrelated": "Badirudi jatorrizkoak ez duela zerikusirik",
-        "Overwrite": "Gainean idatzi",
+        "Overwrite": "Gainidatzi",
         "Workspace": "Lan-eremua",
         "You do not have write access to this site": "Ez duzu gune honetarako idazketa-sarbiderik",
         "You do not have write access to the selected workspace": "Ez duzu hautatutako lan-eremurako idazketa-sarbiderik",
         "Download full document and history": "Jaitsi dokumentu osoa eta historia",
-        "However, it appears to be already identical.": "Hala ere, badirudi jadanik berdina dela.",
+        "However, it appears to be already identical.": "Hala ere, badirudi lehendik berdina dela.",
         "Include the structure without any of the data.": "Sartu egitura, daturik gabe.",
-        "It will be overwritten, losing any content not in this document.": "Gainean idatziko da, dokumentu honetan ez dagoen edukia galduz.",
+        "It will be overwritten, losing any content not in this document.": "Gainidatziko da, dokumentu honetan ez dagoen edukia galduz.",
         "The original version of this document will be updated.": "Dokumentu honen jatorrizko bertsioa eguneratuko da.",
         "To save your changes, please sign up, then reload this page.": "Aldaketak gordetzeko, eman izena eta ondoren birkargatu orri hau.",
         "Replacing the original requires editing rights on the original document.": "Jatorrizkoa ordezkatzeko, jatorrizko dokumentua editatzeko-eskubidea behar da.",
         "Remove all data but keep the structure to use as a template": "Kendu datu guztiak baina gorde egitura txantiloi gisa erabiltzeko",
         "Remove document history (can significantly reduce file size)": "Kendu dokumentuaren historia (fitxategiaren tamaina nabarmen murriztu daiteke)",
-        "Be careful, the original has changes not in this document. Those changes will be overwritten.": "Kontuz, jatorrizkoan egindako aldaketa ez daude dokumentu honetan. Aldaketa horien gainean idatziko da."
+        "Be careful, the original has changes not in this document. Those changes will be overwritten.": "Kontuz, jatorrizkoan egindako aldaketa ez daude dokumentu honetan. Aldaketa horiek gainidatziko dira."
     },
     "NotifyUI": {
         "Ask for help": "Eskatu laguntza",
@@ -593,7 +593,7 @@
         "Fields_one": "Eremua",
         "Fields_other": "Eremuak",
         "Row Style": "Errenkadaren estiloa",
-        "Sort & Filter": "Sailkatu eta iragazi",
+        "Sort & Filter": "Sailkatu eta Iragazi",
         "Theme": "Gaia",
         "You do not have edit access to this document": "Ez duzu dokumentu hau editatzeko sarbiderik",
         "Reset form": "Berrezarri formularioa",
@@ -625,14 +625,14 @@
         "Select Widget": "Hautatu widgeta",
         "TRANSFORM": "ERALDATU",
         "SELECTOR FOR": "HAUTATZAILEA",
-        "Series_one": "Serieak",
-        "Series_other": "Serieak",
+        "Series_one": "Segida",
+        "Series_other": "Segidak",
         "Redirect automatically after submission": "Birbideratu automatikoki bidali ondoren",
         "Submission": "Bidalketa",
         "Table column name": "Taularen zutabearen izena",
-        "Select a field in the form widget to configure.": "Aukeratu widgetaren formularioko eremu bat konfiguratzeko.",
+        "Select a field in the form widget to configure.": "Hautatu widgetaren formularioko eremu bat konfiguratzeko.",
         "Submit button label": "Bidaltzeko botoiaren testua",
-        "Success text": "Arrakastatsua denean erakusteko testua",
+        "Success text": "Bidali denean erakusteko testua",
         "WIDGET TITLE": "WIDGETAREN IZENA",
         "Widget": "Widgeta",
         "Add referenced columns": "Gehitu erreferentziazko zutabeak",
@@ -691,13 +691,13 @@
         "Empty values last": "Balio hutsak amaieran",
         "Natural sort": "Sailkapen naturala",
         "Search Columns": "Bilaketa-zutabeak",
-        "Use choice position": "Erabili posizio hautatua"
+        "Use choice position": "Erabili hautatutako kokapena"
     },
     "SortFilterConfig": {
         "Filter": "IRAGAZI",
         "Save": "Gorde",
         "Sort": "SAILKATU",
-        "Update Sort & Filter settings": "Eguneratu sailkatze- eta iragazketa-ezarpenak",
+        "Update Sort & Filter settings": "Eguneratu Sailkatu eta Iragazi ezarpenak",
         "Revert": "Itzuli"
     },
     "ThemeConfig": {
@@ -740,7 +740,7 @@
         "View Only": "Ikusi soilik",
         "Viewer": "Ikuslea",
         "No Default Access": "Ez dago defektuzko sarbiderik",
-        "In Full": "Bete-betean (osorik)"
+        "In Full": "Osorik"
     },
     "ViewConfigTab": {
         "Form": "Formularioa",
@@ -748,14 +748,14 @@
         "Advanced settings": "Ezarpen aurreratuak",
         "Big tables may be marked as \"on-demand\" to avoid loading them into the data engine.": "Taula handiak \"nahieran\" gisa marka litezke datu-motorrean kargatzea saihesteko.",
         "Compact": "Trinkotu",
-        "Edit Card Layout": "Editatu Karten antolaketa",
+        "Edit Card Layout": "Editatu txartelen antolaketa",
         "Make On-Demand": "Egin nahieran",
         "Plugin: ": "Plugina: ",
         "Unmark On-Demand": "Utzi nahieran egiteari",
         "Blocks": "Blokeak"
     },
     "ViewLayoutMenu": {
-        "Advanced Sort & Filter": "Sailkapen eta iragazki aurreratua",
+        "Advanced Sort & Filter": "Sailkatu eta Iragazi aurreratua",
         "Download as CSV": "Jaitsi CSV gisa",
         "Download as XLSX": "Jaitsi XLSX gisa",
         "Open configuration": "Ireki konfigurazioa",
@@ -769,7 +769,7 @@
         "Data selection": "Datuen hautaketa",
         "Delete record": "Ezabatu erregistroa",
         "Delete widget": "Ezabatu widgeta",
-        "Edit Card Layout": "Editatu Karten antolaketa"
+        "Edit Card Layout": "Editatu txartelen antolaketa"
     },
     "ViewSectionMenu": {
         "(empty)": "(hutsik)",
@@ -780,7 +780,7 @@
         "(customized)": "(pertsonalizatua)",
         "Custom options": "Aukera pertsonalizatuak",
         "Revert": "Itzuli",
-        "Update Sort&Filter settings": "Eguneratu Sailkatu eta Irakazi ezarpenak"
+        "Update Sort&Filter settings": "Eguneratu Sailkatu eta Iragazi ezarpenak"
     },
     "VisibleFieldsConfig": {
         "Clear": "Garbitu",
@@ -790,7 +790,7 @@
         "Show {{label}}": "Erakutsi {{label}}",
         "Hidden {{label}}": "{{label}} ezkutatuta",
         "Visible {{label}}": "{{label}} ikusgai",
-        "Cannot drop items into Hidden Fields": "Ezin dira ezkutatutako eremuetako elementuak deuseztatu"
+        "Cannot drop items into Hidden Fields": "Ezin dira elementuak ezkutatutako eremuetan jarri"
     },
     "WelcomeQuestions": {
         "Education": "Hezkuntza",
@@ -838,7 +838,7 @@
         "Error{{suffix}}": "Errorea{{suffix}}",
         "Page not found{{suffix}}": "Ez da orria aurkitu {{suffix}}",
         "Signed out{{suffix}}": "Saioa amaituta{{suffix}}",
-        "Contact support": "Jarri harremanetan",
+        "Contact support": "Eskatu laguntza",
         "The requested page could not be found.{{separator}}Please check the URL and try again.": "Ezin izan da eskatutako orria aurkitu.{{separator}}Egiaztatu URLa eta saiatu berriro.",
         "You are now signed out.": "Saioa amaitu duzu.",
         "You are signed in as {{email}}. You can sign in with a different account, or ask an administrator for access.": "{{email}} gisa hasi duzu saioa. Beste kontu batekin hasi dezakezu saioa, edo administratzaile bati sarbidea eskatu.",
@@ -855,7 +855,7 @@
         "Choice": "Aukera",
         "Choice List": "Aukeren zerrenda",
         "Attachment": "Eranskina",
-        "* Workspaces are available on team plans. ": "* Lan-eremuak TEAM planetan daude eskuragarri. ",
+        "* Workspaces are available on team plans. ": "* Lan-eremuak TEAM planean daude eskuragarri. ",
         "Upgrade now": "Eguneratu orain",
         "Numeric": "Zenbakizkoa",
         "Integer": "Osoa",
@@ -910,7 +910,7 @@
         "Users from table": "Taulako erabiltzaileak"
     },
     "TypeTransform": {
-        "Apply": "Ezarri",
+        "Apply": "Aplikatu",
         "Cancel": "Utzi",
         "Preview": "Aurrebista",
         "Revise": "Berrikusi",
@@ -954,7 +954,7 @@
         "Save": "Gorde",
         "Reply to a comment": "Erantzun iruzkin bati",
         "Show resolved comments": "Erakutsi konpondutako iruzkinak",
-        "Only my threads": "Nire hariak bakarrik",
+        "Only my threads": "Soilik nire hariak",
         "Started discussion": "Eztabaida hasi du"
     },
     "FieldBuilder": {
@@ -963,11 +963,11 @@
         "CELL FORMAT": "GELAXKEN FORMATUA",
         "DATA FROM TABLE": "TAULAKO DATUAK",
         "Mixed format": "Formatu mistoa",
-        "Mixed types": "Mota mistoak",
+        "Mixed types": "Askotariko motak",
         "Changing multiple column types": "Hainbat zutabe mota aldatzen",
-        "Save field settings for {{colId}} as common": "{{colId}} eremuaren konfigurazioa defektuzko gisa gorde da",
-        "Use separate field settings for {{colId}}": "Erabili eremu-ezarpen bereiziak {{colId}}(e)rako",
-        "Revert field settings for {{colId}} to common": "{{colId}} eremuaren konfigurazioa defektuzkora itzuli da"
+        "Save field settings for {{colId}} as common": "Gorde {{colId}} eremu-ezarpenak arrunt gisa",
+        "Use separate field settings for {{colId}}": "Erabili {{colId}} eremu-ezarpen bereiziak",
+        "Revert field settings for {{colId}} to common": "Itzuli {{colId}} eremu-ezarpenak defektuzkora"
     },
     "FormulaEditor": {
         "Column or field is required": "Beharrezkoa da zutabea edo eremua",
@@ -977,7 +977,7 @@
         "Error in the cell": "Errorea gelaxkan",
         "Expand Editor": "Hedatu editorea",
         "Errors in all {{numErrors}} cells": "Erroreak {{numErrors}} gelaxka guztietan",
-        "editingFormula is required": "editatuFormula beharrezkoa da",
+        "editingFormula is required": "editingFormula beharrezkoa da",
         "Errors in {{numErrors}} of {{numCells}} cells": "Erroreak {{numCells}}eko {{numErrors}} gelaxketan-"
     },
     "NumericTextBox": {
@@ -987,7 +987,7 @@
         "Decimals": "Dezimalak",
         "Currency": "Moneta",
         "Field Format": "Eremuaren formatua",
-        "Spinner": "Spinner",
+        "Spinner": "Spinnerra",
         "max": "max",
         "min": "min"
     },
@@ -1011,16 +1011,16 @@
         "Browse our {{templateLibrary}} to discover what's possible and get inspired.": "Arakatu gure {{templateLibrary}} aukerak ikusi eta inspiratzeko.",
         "Customizing columns": "Zutabeak pertsonalizatzen",
         "Double-click or hit {{enter}} on a cell to edit it. ": "Egin klik birritan edo sakatu {{enter}} gelaxka batean editatzeko. ",
-        "Flying higher": "Gorago hegan",
+        "Flying higher": "Urrunago joateko",
         "Reference": "Erreferentzia",
-        "Make it relational! Use the {{ref}} type to link tables. ": "Harremanak izan daitezela! Erabili {{ref}} mota taulak lotzeko. ",
+        "Make it relational! Use the {{ref}} type to link tables. ": "Egizu erlazional! Erabili {{ref}} mota taulak lotzeko. ",
         "Use {{helpCenter}} for documentation or questions.": "Erabili {{helpCenter}} dokumentaziorako edo galderetarako.",
-        "Use {{addNew}} to add widgets, pages, or import more data. ": "Erabili {{addNew}} widgetak, orriak edo datu gehiago inportatzeko. ",
+        "Use {{addNew}} to add widgets, pages, or import more data. ": "Erabili {{addNew}} widgetak edo orriak gehitzeko, edo datu gehiago inportatzeko. ",
         "creator panel": "sortzailearen mahaigaina",
         "Start with {{equal}} to enter a formula.": "Hasi {{equal}}ekin formula bat sartzeko.",
-        "Set formatting options, formulas, or column types, such as dates, choices, or attachments. ": "Ezarri formatu-aukerak, formulak edo zutabe-motak, hala nola datak, aukerak edo eranskinak. ",
-        "Toggle the {{creatorPanel}} to format columns, ": "Erabili {{creatorPanel}} zutabeak formatatzeko, ",
-        "convert to card view, select data, and more.": "txartel bista, datuak aukeratu, eta gehiago."
+        "Set formatting options, formulas, or column types, such as dates, choices, or attachments. ": "Ezarri formatu-aukerak, formulak, edo zutabe-motak; datak, aukerak edo eranskinak adibidez. ",
+        "Toggle the {{creatorPanel}} to format columns, ": "Erabili {{creatorPanel}} zutabeei formatua emateko, ",
+        "convert to card view, select data, and more.": "txartel-bista bihurtzeko, datuak hautatzeko, eta gehiagorako."
     },
     "LanguageMenu": {
         "Language": "Hizkuntza"
@@ -1034,7 +1034,7 @@
         "Calendar": "Egutegia",
         "Example: {{example}}": "Adibidea: {{example}}",
         "Learn more": "Ikasi gehiago",
-        "Editing Card Layout": "Editatu karten antolaketa",
+        "Editing Card Layout": "Txartelen antolaketa editatzen",
         "Formulas that trigger in certain cases, and store the calculated value as data.": "Kasu jakin batzuetan abiarazten diren formulak, eta kalkulatutako balioak datu gisa gordetzen dute.",
         "Link your new widget to an existing widget on this page.": "Lotu zure widget berria orrialde honetan lehendik dagoen widget batekin.",
         "Raw Data page": "Datu gordinen orria",
@@ -1054,7 +1054,7 @@
         "Apply conditional formatting to rows based on formulas.": "Aplikatu baldintza-formatua formuletan oinarritzen diren errenkadei.",
         "Click the Add New button to create new documents or workspaces, or import data.": "Egin klik \"Gehitu berria\" botoian dokumentu edo lan-eremu berriak sortzeko, edo datuak inportatzeko.",
         "Click on “Open row styles” to apply conditional formatting to rows.": "Egin klik \"Ireki errenkada-estiloak\"-en errenkadei formatu-baldintzak aplikatzeko.",
-        "Nested Filtering": "Iragazki habiratuak",
+        "Nested Filtering": "iragazki habiratuak",
         "Pinned filters are displayed as buttons above the widget.": "Finkatutako iragazkiak botoi gisa ageri dira widgetaren gainean.",
         "Select the table containing the data to show.": "Hautatu erakutsi beharreko datuak dituen taula.",
         "Only those rows will appear which match all of the filters.": "Iragazki guztiekin bat datozen errenkadak baino ez dira agertuko.",
@@ -1065,23 +1065,23 @@
         "Access rules give you the power to create nuanced rules to determine who can see or edit which parts of your document.": "Sarbide-arauek arau zehatzak sortzeko aukera ematen dizute, zure dokumentuaren zein zati nork ikusi edo editatu dezakeen zehazteko.",
         "Anchor Links": "Aingura-estekak",
         "Custom Widgets": "Widget pertsonalizatuak",
-        "entire": "osorik",
-        "Useful for storing the timestamp or author of a new record, data cleaning, and more.": "Baliagarria da data-zigilu edo erregistro berri baten egilea gordetzeko, datuak garbitzeko, eta gehiago.",
+        "entire": "osoa",
+        "Useful for storing the timestamp or author of a new record, data cleaning, and more.": "Baliagarria da data-zigilu edo erregistro berri baten egilea gordetzeko, datuak garbitzeko, eta gehiagorako.",
         "To make an anchor link that takes the user to a specific cell, click on a row and press {{shortcut}}.": "Erabiltzailea gelaxka zehatz batera eramaten duen aingura-esteka bat sortzeko, egin klik errenkada batean eta sakatu {{shortcut}}.",
         "You can choose one of our pre-made widgets or embed your own by providing its full URL.": "Aldez aurretik egindako widget bat aukeratu dezakezu edo zurea txertatu URL osoa emanez.",
         "To configure your calendar, select columns for start": {
-            "end dates and event titles. Note each column's type.": "Zure egutegia konfiguratzeko, aukeratu zutabeak hasierako/amaierako datetarako eta gertaeren izenburuetarako. Zehaztu zutabe bakoitzaren mota."
+            "end dates and event titles. Note each column's type.": "Zure egutegia konfiguratzeko, hautatu zutabeak hasierako/amaierako datetarako eta gertaeren izenburuetarako. Zehaztu zutabe bakoitzaren mota."
         },
         "A UUID is a randomly-generated string that is useful for unique identifiers and link keys.": "UUID bat ausaz sortutako kate bat da, identifikatzaile eta lotura-tekla berezietarako baliagarria dena.",
         "Lookups return data from related tables.": "Bilaketek erlazionatutako tauletatik datuak itzultzen dituzte.",
         "Use reference columns to relate data in different tables.": "Erabili erreferentzia-zutabeak taula ezberdinetako datuak erlazionatzeko.",
         "You can choose from widgets available to you in the dropdown, or embed your own by providing its full URL.": "Goitibeheran dauden widgeten artean aukeratu dezakezu, edo zurea txertatu URL osoa emanez.",
-        "Use the \\u{1D6BA} icon to create summary (or pivot) tables, for totals or subtotals.": "Erabili \\u{1D6BA} ikonoa laburpen- (edo pibote-) taulak sortzeko, totaletarako edo azpi-totaletarako.",
-        "Cells in a reference column always identify an {{entire}} record in that table, but you may select which column from that record to show.": "Erreferentzia-zutabe bateko gelaxkek beti identifikatzen dute erregistro {{entire}} bat taula horretan, baina erregistro horretako zein zutabe erakutsi hautatu dezakezu.",
-        "Linking Widgets": "Widgetak lotzen (erlazionatzen)",
+        "Use the \\u{1D6BA} icon to create summary (or pivot) tables, for totals or subtotals.": "Erabili \\u{1D6BA} ikonoa laburpen-taulak edo taula dinamikoak sortzeko, guztizkoetarako edo guztizko partzialetarako.",
+        "Cells in a reference column always identify an {{entire}} record in that table, but you may select which column from that record to show.": "Erreferentzia-zutabe bateko gelaxkek beti identifikatzen dute erregistro {{entire}} taula horretan, baina erregistro horretako zein zutabe erakutsi hautatu dezakezu.",
+        "Linking Widgets": "Widgetak lotzen",
         "Unpin to hide the the button while keeping the filter.": "Utzi finkatzeari botoia ezkutatzeko iragazkia mantendu bitartean.",
         "Select the table to link to.": "Hautatu lotu beharreko taula.",
-        "Use the 𝚺 icon to create summary (or pivot) tables, for totals or subtotals.": "Erabili 𝚺 ikonoa laburpen- (edo pibote-) taulak sortzeko, totaletarako edo azpi-totaletarako.",
+        "Use the 𝚺 icon to create summary (or pivot) tables, for totals or subtotals.": "Erabili 𝚺 ikonoa laburpen-taulak edo taula dinamikoak sortzeko, guztizkoetarako edo guztizko partzialetarako.",
         "Can't find the right columns? Click 'Change Widget' to select the table with events data.": "Ez dituzu zutabe egokiak aurkitzen? Egin klik \"Aldatu widgeta\"-n gertaeren datuak dituen taula hautatzeko.",
         "Clicking {{EyeHideIcon}} in each cell hides the field from this view without deleting it.": "Gelaxka bakoitzeko {{EyeHideIcon}}-en klik eginez gero, eremua ikuspegi honetatik ezkutatuko da ezabatu gabe."
     },
@@ -1094,7 +1094,7 @@
         "Cancel": "Utzi",
         "Save": "Gorde",
         "Column label": "Zutabearen etiketa",
-        "Provide a column label": "Eman zutabeari etiketa bat"
+        "Provide a column label": "Ezarri zutabearen etiketa"
     },
     "Clipboard": {
         "Got it": "Ulertuta",
@@ -1123,7 +1123,7 @@
         "Memo": "Memorandum",
         "Removed webhook.": "Webhooka kendu da.",
         "Webhook Id": "Webhook IDa",
-        "Ready Column": "Prest zutabea",
+        "Ready Column": "Zutabea prest",
         "Filter for changes in these columns (semicolon-separated ids)": "Iragazki aldaketetarako zutabe hauetan (\";\" bidez bareizi IDak)",
         "Header Authorization": "Goiburuko baimena"
     },
@@ -1136,7 +1136,7 @@
         "Need help? Our AI assistant can help.": "Laguntza behar duzu? Gure AA laguntzaileak lagun zaitzake.",
         "Tips": "Aholkuak",
         "AI Assistant": "AA laguntzailea",
-        "Apply": "Ezarri",
+        "Apply": "Aplikatu",
         "Cancel": "Utzi",
         "Learn more": "Ikasi gehiago",
         "Clear Conversation": "Garbitu elkarrizketa",
@@ -1147,7 +1147,7 @@
         "Formula Help. ": "Formulen laguntza ",
         "Function List": "Funtzioen zerrenda",
         "Grist's AI Formula Assistance. ": "Gristen AAeko formula-laguntzailea ",
-        "Formula Cheat Sheet": "Formularen eskuliburua",
+        "Formula Cheat Sheet": "Formulen eskuliburua",
         "Hi, I'm the Grist Formula AI Assistant.": "Kaixo, Gristen AAeko formula-laguntzailea naiz.",
         "Code View": "Kode-ikustailea",
         "See our {{helpFunction}} and {{formulaCheat}}, or visit our {{community}} for more help.": "Ikusi gure {{helpFunction}} eta {{formulaCheat}}, edo bisitatu gure {{community}} laguntza gehiagorako.",
@@ -1167,11 +1167,11 @@
     },
     "ChartView": {
         "Pick a column": "Hautatu zutabea",
-        "Create separate series for each value of the selected column.": "Sortu serie bereiziak hautatutako zutabearen balio bakoitzerako.",
-        "Each Y series is followed by a series for the length of error bars.": "Y serie bakoitzaren ondoren serie bat dago errore-barren luzerarako.",
+        "Create separate series for each value of the selected column.": "Sortu segida bereiziak hautatutako zutabearen balio bakoitzerako.",
+        "Each Y series is followed by a series for the length of error bars.": "Y segida bakoitzaren ondoren errore-barren luzerarako segida bat dator.",
         "Toggle chart aggregation": "Grafikoaren agregazioa bai/ez",
         "selected new group data columns": "hautatutako taldekako datu-zutabe berriak",
-        "Each Y series is followed by two series, for top and bottom error bars.": "Y serie bakoitzaren ondoren bi serie daude, goiko eta beheko errore-barretarako."
+        "Each Y series is followed by two series, for top and bottom error bars.": "Y segida bakoitzaren ondoren goiko eta beheko errore-barrentzako segida bana dator."
     },
     "FilterBar": {
         "SearchColumns": "Bilatu zutabeak",
@@ -1185,8 +1185,8 @@
         "Select fields to match on": "Hautatu bat egiteko eremuak",
         "Update existing records": "Eguneratu lehendik dauden erregistroak",
         "{{count}} unmatched field_other": "Bat ez datozen {{count}} eremu",
-        "Column Mapping": "Zutabeen mapa",
-        "Column mapping": "Zutabeen mapa",
+        "Column Mapping": "Zutabeen mapaketa",
+        "Column mapping": "Zutabeen mapaketa",
         "{{count}} unmatched field in import_one": "Bat ez datorren eremu {{count}} inportazioan",
         "{{count}} unmatched field in import_other": "Bat ez datozen {{count}} eremu inportazioan",
         "{{count}} unmatched field_one": "Bat ez datorren eremu {{count}}",
@@ -1208,7 +1208,7 @@
         "UnknownUser": "Erabiltzaile ezezaguna"
     },
     "TypeTransformation": {
-        "Apply": "Ezarri",
+        "Apply": "Aplikatu",
         "Cancel": "Utzi",
         "Preview": "Aurrebista",
         "Revise": "Berrikusi",
@@ -1226,18 +1226,18 @@
         "Code View is available only when you have full document access.": "Kode-ikustailea dokumentu guztiak eskura dituzunean bakarrik dago eskuragarri."
     },
     "DocTour": {
-        "No valid document tour": "Ez du balio dokumentu-ibilbideak",
-        "Cannot construct a document tour from the data in this document. Ensure there is a table named GristDocTour with columns Title, Body, Placement, and Location.": "Ezin da dokumentu-ibilbide bat egin dokumentu honetako datuetatik abiatuta. GristDocTour izeneko mahai bat dago zutabeekin. Izenburua, gorputza, kokapena eta kokapena."
+        "No valid document tour": "Ez dago baliozko dokumentu-bisitaldirik",
+        "Cannot construct a document tour from the data in this document. Ensure there is a table named GristDocTour with columns Title, Body, Placement, and Location.": "Ezin da dokumentu-bisitaldi bat sortu dokumentu honetako datuetatik abiatuta. Egiaztatu GristDocTour izeneko taula bat dagoela Izenburua, Gorputza, eta Kokapena zutabeekin."
     },
     "Drafts": {
         "Restore last edit": "Leheneratu azken edizioa",
-        "Undo discard": "Desegin baztertzailea"
+        "Undo discard": "Desegin bazterketa"
     },
     "GristDoc": {
         "Import from file": "Inportatu fitxategitik",
         "Added new linked section to view {{viewName}}": "{{viewName}} ikusteko lotutako atal berria gehitu da",
         "go to webhook settings": "Joan webhooken ezarpenetara",
-        "Saved linked section {{title}} in view {{name}}": "{{title}} atala gorde da hemen: {{name}}"
+        "Saved linked section {{title}} in view {{name}}": "{{title}} lotutako atala {{name}} bistan gorde da"
     },
     "OpenVideoTour": {
         "Grist Video Tour": "Gristen bideo-bisitaldia",
@@ -1272,7 +1272,7 @@
         "Convert column to formula": "Bihurtu zutabea formula"
     },
     "FieldEditor": {
-        "It should be impossible to save a plain data value into a formula column": "Ezinezkoa litzateke datu-balio soil bat gordetzea formula-zutabe batean",
+        "It should be impossible to save a plain data value into a formula column": "Ezinezkoa litzateke datu-balio soil bat formula-zutabe batean gordetzea",
         "Unable to finish saving edited cell": "Ezin izan da gelaxka gordetzen amaitu"
     },
     "HyperLinkEditor": {
@@ -1292,16 +1292,16 @@
         "Invite people to {{resourceType}}": "Gonbidatu jendea {{resourceType}}(e)ra",
         "Link copied to clipboard": "Esteka arbelera kopiatu da",
         "On": "Piztuta",
-        "Once you have removed your own access,             you will not be able to get it back without assistance              from someone else with sufficient access to the {{name}}.": "Zure sarbidea kendu ondoren, ezingo duzu berreskuratu {{name}}(e)rako sarbide nahikoa duen norbaiten laguntzarik gabe.",
+        "Once you have removed your own access,             you will not be able to get it back without assistance              from someone else with sufficient access to the {{name}}.": "Zure sarbidea kendu ondoren, ezingo duzu berreskuratu {{name}}(e)rako sarbide nahikoa duen beste norbaiten laguntzarik gabe.",
         "Public access": "Sarbide publikoa",
-        "Public access inherited from {{parent}}. To remove, set 'Inherit access' option to 'None'.": "Sarbide publikoak {{parent}}(r)en baimenak hartzen ditu. Kentzeko, ezarri \"Jaso sarbidea\" \"Bat ere ez\" aukerara.",
+        "Public access inherited from {{parent}}. To remove, set 'Inherit access' option to 'None'.": "Sarbide publikoak {{parent}}(r)en baimenak jasotzen ditu. Kentzeko, ezarri \"Jarauntsitako sarbidea\" \"Bat ere ez\" aukerara.",
         "Public access: ": "Sarbide publikoa: ",
         "Remove my access": "Kendu nire sarbidea",
         "member": "kidea",
         "team site": "Taldearen gunea",
-        "{{limitAt}} of {{limitTop}} {{collaborator}}s": "{{limitAt}} of {{limitTop}} {{collaborator}}s",
+        "{{limitAt}} of {{limitTop}} {{collaborator}}s": "{{limitTop}}(e)tik {{limitAt}} {{collaborator}}",
         "{{collaborator}} limit exceeded": "{{collaborator}}-muga gainditu da",
-        "User inherits permissions from {{parent}}. To remove, set 'Inherit access' option to 'None'.": "Erabiltzaileak {{parent}}(r)en baimenak hartzen ditu. Kentzeko, ezarri \"Jaso sarbidea\" \"Bat ere ez\" aukerara.",
+        "User inherits permissions from {{parent}}. To remove, set 'Inherit access' option to 'None'.": "Erabiltzaileak {{parent}}(r)en baimenak jasotzen ditu. Kentzeko, ezarri \"Jarauntsitako sarbidea\" \"Bat ere ez\" aukerara.",
         "Anyone with link ": "Esteka duen edonor ",
         "Cancel": "Utzi bertan behera",
         "Close": "Itxi",
@@ -1321,17 +1321,17 @@
         "guest": "gonbidatua",
         "No default access allows access to be granted to individual documents or workspaces, rather than the full team site.": "Ez dago dokumentu indibidualetarako edo lan-guneetarako defektuzko-sarbiderik, lantaldearen gune osora baizik.",
         "You are about to remove your own access to this {{resourceType}}": "{{resourceType}} honetarako zure sarbidea ezabatzear zaude",
-        "User inherits permissions from {{parent})}. To remove,           set 'Inherit access' option to 'None'.": "Erabiltzaileak {{parent})} -ren baimenak heredatzen ditu. Kentzeko, ezarri \"Heredatu sarbidea\" aukera \"Bat ere ez\" aukerara.",
-        "free collaborator": "Kolaboratzaile askea",
-        "Once you have removed your own access, you will not be able to get it back without assistance from someone else with sufficient access to the {{resourceType}}.": "Zure sarbidea kendu eta gero, ezingo duzu berreskuratu {{resourceType}}(e)rako sarbidea nahikoa duen norbaiten laguntzarik gabe.",
-        "User has view access to {{resource}} resulting from manually-set access to resources inside. If removed here, this user will lose access to resources inside.": "Erabiltzaileak {{resource}}-rako bistarako sarbidea du, barneko baliabideetarako sarbidea eskuz ezartzearen ondorioz. Hemendik kenduz gero, barruan dauden baliabideak galduko ditu."
+        "User inherits permissions from {{parent})}. To remove,           set 'Inherit access' option to 'None'.": "Erabiltzaileak {{parent}}(r)en baimenak jasotzen ditu. Kentzeko, ezarri \"Jarauntsitako sarbidea\" aukera \"Bat ere ez\" aukerara.",
+        "free collaborator": "kolaboratzaile askea",
+        "Once you have removed your own access, you will not be able to get it back without assistance from someone else with sufficient access to the {{resourceType}}.": "Zure sarbidea kendu ondoren, ezingo duzu berreskuratu {{resourceType}}(e)rako sarbide nahikoa duen beste norbaiten laguntzarik gabe.",
+        "User has view access to {{resource}} resulting from manually-set access to resources inside. If removed here, this user will lose access to resources inside.": "Erabiltzaileak {{resource}}-rako bistarako sarbidea du, barneko baliabideetarako sarbidea eskuz ezartzearen ondorioz. Hemendik kenduz gero, barruan dauden baliabideetarako sarbidea galduko du."
     },
     "SupportGristNudge": {
         "Help Center": "Laguntza-gunea",
         "Opt in to Telemetry": "Bidali telemetria",
         "Support Grist": "Eman babesa Grist-i",
         "Support Grist page": "Eman babesa Grist-en orriari",
-        "Opted In": "Onartua",
+        "Opted In": "Izena emanda",
         "Close": "Itxi",
         "Contribute": "Hartu parte",
         "Thank you! Your trust and support is greatly appreciated. Opt out any time from the {{link}} in the user menu.": "Mila esker! Zure konfiantza eta babesa oso estimatua da. Edozein unetan bidaltzeari utzi diezaiokezu erabiltzailearen menuko {{link}}tik.",
@@ -1351,7 +1351,7 @@
         "Sponsor Grist Labs on GitHub": "Eman babesa Grist Labs-i GitHuben",
         "This instance is opted in to telemetry. Only the site administrator has permission to change this.": "Instantzia honek telemetria bidaltzea aukeratu du. Administratzaileak bakarrik du aukera hau aldatzeko baimena.",
         "This instance is opted out of telemetry. Only the site administrator has permission to change this.": "Instantzia honek telemetria ez bidaltzea aukeratu du. Administratzaileak bakarrik du aukera hau aldatzeko baimena.",
-        "You can opt out of telemetry at any time from this page.": "Orri honeta telemetria ez bidaltzea aukera dezakezu edozein unetan.",
+        "You can opt out of telemetry at any time from this page.": "Orri honetan telemetria ez bidaltzea aukera dezakezu edozein unetan.",
         "You have opted in to telemetry. Thank you!": "Telemetria bidaltzea aukeratu duzu. Mila esker!",
         "You have opted out of telemetry.": "Telemetria ez bidaltzea aukeratu duzu.",
         "Manage Sponsorship": "Kudeatu babesletza"
@@ -1401,7 +1401,7 @@
         "Reset form": "Berrezarri formularioa",
         "Share": "Partekatu",
         "Save your document to publish this form.": "Gorde dokumentua formularioa argitaratzeko.",
-        "Preview": "Aurreikusi"
+        "Preview": "Aurrebista"
     },
     "Editor": {
         "Delete": "Ezabatu"
@@ -1416,25 +1416,25 @@
         "Paragraph": "Paragrafoa",
         "Paste": "Itsatsi",
         "Separator": "Bereizgailua",
-        "Unmapped fields": "Mapeatu gabeko eremuak",
+        "Unmapped fields": "Mapaketatu gabeko eremuak",
         "Header": "Goiburua"
     },
     "UnmappedFieldsConfig": {
         "Clear": "Garbitu",
-        "Select All": "Hautatu guztiaa",
-        "Map fields": "Mapeatu eremuak",
-        "Mapped": "Mapeatuta",
-        "Unmap fields": "Desmapeatu eremuak",
-        "Unmapped": "Desmapeatuta"
+        "Select All": "Hautatu guztia",
+        "Map fields": "Mapaketatu eremuak",
+        "Mapped": "Mapaketatuta",
+        "Unmap fields": "Desmapaketatu eremuak",
+        "Unmapped": "Mapaketatu gabe"
     },
     "FormConfig": {
         "Default": "Defektuzkoa",
         "Field Format": "Eremu-formatua",
-        "Ascending": "Goranzkoa",
+        "Ascending": "Gorantz",
         "Descending": "Beherantz",
         "Select": "Hautatu",
         "Vertical": "Bertikala",
-        "Radio": "Radioa",
+        "Radio": "Aukera-botoia",
         "Field rules": "Eremu-arauak",
         "Required field": "Nahitaezko eremua",
         "Field Rules": "Eremu-arauak",
@@ -1446,7 +1446,7 @@
         "Oops! The form you're looking for doesn't exist.": "Hara! Bilatzen ari zaren formularioa ez da existitzen.",
         "There was a problem loading the form.": "Arazo bat egon da formularioa kargatzean.",
         "You don't have access to this form.": "Ez duzu formulario honetarako sarbiderik.",
-        "Oops! This form is no longer published.": "Hara! Formulario hau ez dago argitaratuta egoteari utzi dio."
+        "Oops! This form is no longer published.": "Hara! Formularioak argitaratuta egoteari utzi dio."
     },
     "FormPage": {
         "There was an error submitting your form. Please try again.": "Errore bat egon da zure formularioa bidaltzean. Saiatu berriro."
@@ -1468,11 +1468,11 @@
     },
     "MappedFieldsConfig": {
         "Clear": "Garbitu",
-        "Map fields": "Mapeatu eremuak",
-        "Mapped": "Mapeatuta",
+        "Map fields": "Mapaketatu eremuak",
+        "Mapped": "Mapaketatuta",
         "Select All": "Hautatu guztia",
-        "Unmapped": "Desmapeatuta",
-        "Unmap fields": "Desmapeatu eremuak"
+        "Unmapped": "Mapaketatu gabe",
+        "Unmap fields": "Desmapaketatu eremuak"
     },
     "CreateTeamModal": {
         "Cancel": "Utzi",
@@ -1481,7 +1481,7 @@
         "Team name": "Taldearen izena",
         "Team name is required": "Taldearen izena beharrezkoa da",
         "Work as a Team": "Egin lan taldean",
-        "Billing is not supported in grist-core": "Fakturazioa ez da bateragarria grist-core-rekin",
+        "Billing is not supported in grist-core": "grist-corek ez du fakturazioa onartzen",
         "Choose a name and url for your team site": "Aukeratu zure talderako izen eta URL bat",
         "Create site": "Sortu gunea",
         "Domain name is invalid": "Domeinuaren izenak ez du balio",
@@ -1490,8 +1490,8 @@
     },
     "AdminPanel": {
         "Current version of Grist": "Grist-en uneko bertsioa",
-        "Admin Panel": "Administratzaile Panela",
-        "Current": "Uneko",
+        "Admin Panel": "Administratzailearen mahaigaina",
+        "Current": "Unekoa",
         "Help us make Grist better": "Lagun gaitzazu Grist hobetzen",
         "Home": "Hasiera",
         "Sponsor": "Babeslea",
@@ -1502,7 +1502,7 @@
         "Error": "Errorea",
         "Error checking for updates": "Errorea eguneraketak bilatzean",
         "Grist is up to date": "Grist egunean dago",
-        "Grist signs user session cookies with a secret key. Please set this key via the environment variable GRIST_SESSION_SECRET. Grist falls back to a hard-coded default when it is not set. We may remove this notice in the future since session IDs have been updated to be inherently cryptographically secure.": "Grist-ek erabiltzailearen saioen cookieak giltza sekretu batekin sinatzen ditu. Ezarri giltza hau GRIST_SESSION_SECRET aldagaiaren bidez. Grist kodifikazio gogorreko akats batera erortzen da ezarrita ez dagoenean. Abisu hau etorkizunean ezaba dezakegu... v1.1.16tik sortutako saio-identifikazioak kriptografikoki seguruak direlako.",
+        "Grist signs user session cookies with a secret key. Please set this key via the environment variable GRIST_SESSION_SECRET. Grist falls back to a hard-coded default when it is not set. We may remove this notice in the future since session IDs have been updated to be inherently cryptographically secure.": "Grist-ek erabiltzaileen saioen cookieak gako sekretu batekin sinatzen ditu. Ezarri gako hau GRIST_SESSION_SECRET aldagaiaren bidez. Ezarrita ez dagoenean, defektuzkora joko du. Abisu hau etorkizunean ezaba dezakegu v1.1.16tik aurrera kriptografikoki seguruak diren saio-identifikazioak sortzen direlako.",
         "Learn more.": "Ikasi gehiago.",
         "Newer version available": "Eskuragarri dago bertsio berriago bat",
         "OK": "Ados",
@@ -1516,9 +1516,9 @@
         "Grist allows different types of authentication to be configured, including SAML and OIDC.     We recommend enabling one of these if Grist is accessible over the network or being made available     to multiple people.": "Gristek autentifikazio mota ezberdinak konfiguratzen uzten du, SAML eta OIDC barne. Horietako bat baimentzea gomendatzen dugu Grist lokaletik kanpo badago edo pertsona askoren eskura jartzen bada.",
         "Results": "Emaitzak",
         "Self Checks": "Norbere egiaztatzeak",
-        "Or, as a fallback, you can set: {{bootKey}} in the environment and visit: {{url}}": "Edo, falback gisa, ingurunean {{bootKey}} jar dezakezu eta {{url}} bisitatu",
+        "Or, as a fallback, you can set: {{bootKey}} in the environment and visit: {{url}}": "Edo, bigarren aukera gisa, aldagaian {{bootKey}} jar dezakezu eta {{url}} bisitatu",
         "Grist allows different types of authentication to be configured, including SAML and OIDC. We recommend enabling one of these if Grist is accessible over the network or being made available to multiple people.": "Gristek autentifikazio mota ezberdinak konfiguratzen uzten du, SAML eta OIDC barne. Horietako bat baimentzea gomendatzen dugu Grist lokaletik kanpo badago edo pertsona askoren eskura jartzen bada.",
-        "Grist signs user session cookies with a secret key. Please set this key via the environment variable GRIST_SESSION_SECRET. Grist falls back to a hard-coded default when it is not set. We may remove this notice in the future as session IDs generated since v1.1.16 are inherently cryptographically secure.": "Grist-ek erabiltzailearen saioen cookieak giltza sekretu batekin sinatzen ditu. Ezarri giltza hau GRIST_SESSION_SECRET aldagaiaren bidez. Grist kodifikazio gogorreko akats batera erortzen da ezarrita ez dagoenean. Abisu hau etorkizunean ezaba dezakegu... v1.1.16tik sortutako saio-identifikazioak kriptografikoki seguruak direlako.",
+        "Grist signs user session cookies with a secret key. Please set this key via the environment variable GRIST_SESSION_SECRET. Grist falls back to a hard-coded default when it is not set. We may remove this notice in the future as session IDs generated since v1.1.16 are inherently cryptographically secure.": "Grist-ek erabiltzaileen saioen cookieak gako sekretu batekin sinatzen ditu. Ezarri gako hau GRIST_SESSION_SECRET aldagaiaren bidez. Ezarrita ez dagoenean, defektuzkora joko du. Abisu hau etorkizunean ezaba dezakegu v1.1.16tik aurrera kriptografikoki seguruak diren saio-identifikazioak sortzen direlako.",
         "Support Grist Labs on GitHub": "Eman babesa Grist Labs-i GitHuben",
         "Telemetry": "Telemetria",
         "Version": "Bertsioa",
@@ -1532,9 +1532,9 @@
         "Current authentication method": "Uneko autentifikazio-metodoa",
         "No fault detected.": "Ez da akatsik antzeman.",
         "Notes": "Oharrak",
-        "You do not have access to the administrator panel.\nPlease log in as an administrator.": "Ez duzu administratzaile-mahaigainera sarbiderik. Hasi saioa administratzaile gisa.",
+        "You do not have access to the administrator panel.\nPlease log in as an administrator.": "Ez duzu administratzailearen mahaigainera sarbiderik.\nHasi saioa administratzaile gisa.",
         "Key to sign sessions with": "Saioak sinatzeko gakoa",
-        "Grist allows for very powerful formulas, using Python. We recommend setting the environment variable GRIST_SANDBOX_FLAVOR to gvisor if your hardware supports it (most will), to run formulas in each document within a sandbox isolated from other documents and isolated from the network.": "Gristek formula oso boteretsuak onartzen ditu, Piton erabiliz. GRIST_SANDBOX_FLAVOR ingurumen aldagaia gvisorrari jartzea gomendatzen dugu, zure hardwareak eusten badio (gehienak borondatezkoak), dokumentu bakoitzean formulak exekutatzeko beste dokumentu batzuetatik isolatutako eta saretik isolatutako sandbox baten barruan.",
+        "Grist allows for very powerful formulas, using Python. We recommend setting the environment variable GRIST_SANDBOX_FLAVOR to gvisor if your hardware supports it (most will), to run formulas in each document within a sandbox isolated from other documents and isolated from the network.": "Gristek formula oso boteretsuak onartzen ditu, Python erabiliz. Dokumentu bakoitzean beste dokumentu batzuetatik eta saretik isolatutako sandbox baten barruan formulak exekutatzeko, GRIST_SANDBOX_FLAVOR aldagaia gvisor-era aldatzea gomendatzen dugu, zure hardwarea bateragarria bada (gehienak badira).",
         "Session Secret": "Saioaren gakoa"
     },
     "Columns": {
@@ -1545,7 +1545,7 @@
         "No values in show column of referenced table": "Ez dago baliorik erakusten den zutabean edo erreferentzia-taulan"
     },
     "Toggle": {
-        "Checkbox": "Aukera-kutxa",
+        "Checkbox": "Kontrol-laukia",
         "Field Format": "Eremuaren formatua",
         "Switch": "Aldatu"
     },
@@ -1577,22 +1577,22 @@
         "Table": "Taula",
         "Calendar": "Egutegia",
         "Card": "Txartela",
-        "Card List": "Txartelen zerrenda"
+        "Card List": "Txartel-zerrenda"
     },
     "TimingPage": {
         "Formula timer": "Formula-kronometroa-",
-        "Loading timing data. Don't close this tab.": "Denboren datuak kargatzen. Ez itxi fitxa hau.",
+        "Loading timing data. Don't close this tab.": "Kronometroaren datuak kargatzen. Ez itxi fitxa hau.",
         "Max Time (s)": "Denbora maximoa (s)",
         "Average Time (s)": "Batez besteko denbora(k) (s)",
         "Column ID": "Zutabearen IDa",
         "Table ID": "Taularen IDa",
         "Total Time (s)": "Denbora guztira (s)",
-        "Number of Calls": "Eskaera-kopurua"
+        "Number of Calls": "Eskaera kopurua"
     },
     "WelcomeSitePicker": {
         "Welcome back": "Ongi etorri",
         "You have access to the following Grist sites.": "Grist gune hauetarako sarbidea duzu.",
-        "You can always switch sites using the account menu.": "Beti alda ditzakezu guneak kontuaren menua erabiliz."
+        "You can always switch sites using the account menu.": "Kontuaren menua erabiliz beti alda ditzakezu guneak."
     },
     "SearchModel": {
         "Search all pages": "Bilatu orri guztiak",
@@ -1609,8 +1609,8 @@
         "Hidden fields": "Ezkutatutako eremuak"
     },
     "CustomView": {
-        "Some required columns aren't mapped": "Nahitaezko zutabe batzuk ez daude mapeatuta",
-        "To use this widget, please map all non-optional columns from the creator panel on the right.": "Widget hau erabiltzeko, mapeatu aukerakoak ez diren zutabeak eskuineko sortzaileen mahaigainetik."
+        "Some required columns aren't mapped": "Nahitaezko zutabe batzuk ez daude mapaketatuta",
+        "To use this widget, please map all non-optional columns from the creator panel on the right.": "Widget hau erabiltzeko, mapaketatu aukerakoak ez diren zutabeak sortzaileen mahaigainetik, eskuinean."
     },
     "FormContainer": {
         "Build your own form": "Sortu zure formularioa",

From f9e7558410cb019e72511cf59b7282423fe8b94b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= <jordigh@octave.org>
Date: Thu, 18 Jul 2024 17:38:19 -0400
Subject: [PATCH 087/145] Dockerfile: silence a warning

Docker doesn't like mixed case in keywords.
---
 Dockerfile | 8 ++++----
 1 file changed, 4 insertions(+), 4 deletions(-)

diff --git a/Dockerfile b/Dockerfile
index cdd584f7..52a79818 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -4,13 +4,13 @@
 ##   docker buildx build -t ... --build-context=ext=<path> .
 ## The code in <path> will then be built along with the rest of Grist.
 ################################################################################
-FROM scratch as ext
+FROM scratch AS ext
 
 ################################################################################
 ## Javascript build stage
 ################################################################################
 
-FROM node:18-buster as builder
+FROM node:18-buster AS builder
 
 # Install all node dependencies.
 WORKDIR /grist
@@ -46,7 +46,7 @@ RUN \
 ################################################################################
 
 # Fetch python3.11 and python2.7
-FROM python:3.11-slim-buster as collector
+FROM python:3.11-slim-buster AS collector
 
 # Install all python dependencies.
 ADD sandbox/requirements.txt requirements.txt
@@ -66,7 +66,7 @@ RUN \
 # Fetch gvisor-based sandbox. Note, to enable it to run within default
 # unprivileged docker, layers of protection that require privilege have
 # been stripped away, see https://github.com/google/gvisor/issues/4371
-FROM docker.io/gristlabs/gvisor-unprivileged:buster as sandbox
+FROM docker.io/gristlabs/gvisor-unprivileged:buster AS sandbox
 
 ################################################################################
 ## Run-time stage

From f0aacc4d9684f22b1dae1eff98cc9c8dee612b18 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= <jordigh@octave.org>
Date: Mon, 22 Jul 2024 09:43:29 -0400
Subject: [PATCH 088/145] config: end the file with a newline

Small cosmetic change, POSIX requires final newlines in text files.

https://stackoverflow.com/questions/729692/why-should-text-files-end-with-a-newline
---
 app/server/lib/config.ts | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/app/server/lib/config.ts b/app/server/lib/config.ts
index 490cddcf..f6681546 100644
--- a/app/server/lib/config.ts
+++ b/app/server/lib/config.ts
@@ -98,7 +98,7 @@ export class FileConfig<FileContents> {
   }
 
   public async persistToDisk() {
-    await Deps.writeFile(this._filePath, JSON.stringify(this._rawConfig, null, 2));
+    await Deps.writeFile(this._filePath, JSON.stringify(this._rawConfig, null, 2) + "\n");
   }
 }
 

From d57c3f068d60f988db8a14a9b75e6a6c4c37f898 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= <jordigh@octave.org>
Date: Mon, 22 Jul 2024 09:45:12 -0400
Subject: [PATCH 089/145] configCore: default to enterprise edition if
 TEST_ENABLE_ACTIVATION is truthy

This will ensure that the grist-ee image will have a consistent config
setting when created from the default value.
---
 app/server/lib/configCore.ts | 8 ++++++--
 1 file changed, 6 insertions(+), 2 deletions(-)

diff --git a/app/server/lib/configCore.ts b/app/server/lib/configCore.ts
index 58ddf627..99c3770b 100644
--- a/app/server/lib/configCore.ts
+++ b/app/server/lib/configCore.ts
@@ -4,7 +4,8 @@ import {
   fileConfigAccessorFactory,
   IWritableConfigValue
 } from "./config";
-import { convertToCoreFileContents, IGristCoreConfigFileLatest } from "./configCoreFileFormats";
+import {convertToCoreFileContents, IGristCoreConfigFileLatest} from "./configCoreFileFormats";
+import {isAffirmative} from 'app/common/gutil';
 
 export type Edition = "core" | "enterprise";
 
@@ -23,6 +24,9 @@ export function loadGristCoreConfigFile(configPath?: string): IGristCoreConfig {
 export function loadGristCoreConfig(fileConfig?: FileConfig<IGristCoreConfigFileLatest>): IGristCoreConfig {
   const fileConfigValue = fileConfigAccessorFactory(fileConfig);
   return {
-    edition: createConfigValue<Edition>("core", fileConfigValue("edition"))
+    edition: createConfigValue<Edition>(
+      isAffirmative(process.env.TEST_ENABLE_ACTIVATION) ? "enterprise" : "core",
+      fileConfigValue("edition")
+    )
   };
 }

From 2d85ed1bfebc8e44c9a4f31ad6b83bd99d629115 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= <jordigh@octave.org>
Date: Mon, 22 Jul 2024 09:47:52 -0400
Subject: [PATCH 090/145] config: new API endpoint

This adds PATCH and GET endpoints to handle `config.json`.
---
 app/server/lib/ConfigBackendAPI.ts | 35 ++++++++++++++++++++++++++++++
 app/server/lib/FlexServer.ts       | 11 +++++++++-
 app/server/mergedServerMain.ts     |  1 +
 3 files changed, 46 insertions(+), 1 deletion(-)
 create mode 100644 app/server/lib/ConfigBackendAPI.ts

diff --git a/app/server/lib/ConfigBackendAPI.ts b/app/server/lib/ConfigBackendAPI.ts
new file mode 100644
index 00000000..afb475fa
--- /dev/null
+++ b/app/server/lib/ConfigBackendAPI.ts
@@ -0,0 +1,35 @@
+import * as express from 'express';
+import {expressWrap} from 'app/server/lib/expressWrap';
+
+import {getGlobalConfig} from 'app/server/lib/globalConfig';
+
+import log from "app/server/lib/log";
+
+export class ConfigBackendAPI {
+  public addEndpoints(app: express.Express, requireInstallAdmin: express.RequestHandler) {
+    app.get('/api/config/:key', requireInstallAdmin, expressWrap((req, resp) => {
+      log.debug('config: requesting configuration', req.params);
+
+      // Only one key is valid for now
+      if (req.params.key === 'edition') {
+        resp.send({value: getGlobalConfig().edition.get()});
+      } else {
+        resp.status(404).send({ error: 'Configuration key not found.' });
+      }
+    }));
+
+    app.patch('/api/config', requireInstallAdmin, expressWrap(async (req, resp) => {
+      const config = req.body.config;
+      log.debug('config: received new configuration item', config);
+
+      // Only one key is valid for now
+      if(config.edition !== undefined) {
+        await getGlobalConfig().edition.set(config.edition);
+
+        resp.send({ msg: 'ok' });
+      } else {
+        resp.status(400).send({ error: 'Invalid configuration key' });
+      }
+    }));
+  }
+}
diff --git a/app/server/lib/FlexServer.ts b/app/server/lib/FlexServer.ts
index 06e9b855..0b5f9ea8 100644
--- a/app/server/lib/FlexServer.ts
+++ b/app/server/lib/FlexServer.ts
@@ -87,7 +87,8 @@ import {AddressInfo} from 'net';
 import fetch from 'node-fetch';
 import * as path from 'path';
 import * as serveStatic from "serve-static";
-import {IGristCoreConfig} from "./configCore";
+import {ConfigBackendAPI} from "app/server/lib/ConfigBackendAPI";
+import {IGristCoreConfig} from "app/server/lib/configCore";
 
 // Health checks are a little noisy in the logs, so we don't show them all.
 // We show the first N health checks:
@@ -1948,6 +1949,14 @@ export class FlexServer implements GristServer {
     }));
   }
 
+  public addConfigEndpoints() {
+    // Need to be an admin to change the Grist config
+    const requireInstallAdmin = this.getInstallAdmin().getMiddlewareRequireAdmin();
+
+    const configBackendAPI = new ConfigBackendAPI();
+    configBackendAPI.addEndpoints(this.app, requireInstallAdmin);
+  }
+
   // Get the HTML template sent for document pages.
   public async getDocTemplate(): Promise<DocTemplate> {
     const page = await fse.readFile(path.join(getAppPathTo(this.appRoot, 'static'),
diff --git a/app/server/mergedServerMain.ts b/app/server/mergedServerMain.ts
index 466eddaf..1bef819d 100644
--- a/app/server/mergedServerMain.ts
+++ b/app/server/mergedServerMain.ts
@@ -165,6 +165,7 @@ export async function main(port: number, serverTypes: ServerType[],
       server.addLogEndpoint();
       server.addGoogleAuthEndpoint();
       server.addInstallEndpoints();
+      server.addConfigEndpoints();
     }
 
     if (includeDocs) {

From bc8e5f68378914e21b975b8caf01c533b1c419fb Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= <jordigh@octave.org>
Date: Sun, 28 Jul 2024 17:59:48 -0400
Subject: [PATCH 091/145] FlexServer: remove config from restart endpoint

The config endpoint now handles changing config values, so we only
need to handle restarts here.
---
 app/server/lib/FlexServer.ts | 5 ++---
 1 file changed, 2 insertions(+), 3 deletions(-)

diff --git a/app/server/lib/FlexServer.ts b/app/server/lib/FlexServer.ts
index 0b5f9ea8..15429b2d 100644
--- a/app/server/lib/FlexServer.ts
+++ b/app/server/lib/FlexServer.ts
@@ -1877,14 +1877,13 @@ export class FlexServer implements GristServer {
     const probes = new BootProbes(this.app, this, '/api', adminMiddleware);
     probes.addEndpoints();
 
-    this.app.post('/api/admin/restart', requireInstallAdmin, expressWrap(async (req, resp) => {
-      const newConfig = req.body.newConfig;
+    this.app.post('/api/admin/restart', requireInstallAdmin, expressWrap(async (_, resp) => {
       resp.on('finish', () => {
         // If we have IPC with parent process (e.g. when running under
         // Docker) tell the parent that we have a new environment so it
         // can restart us.
         if (process.send) {
-          process.send({ action: 'restart', newConfig });
+          process.send({ action: 'restart' });
         }
       });
       // On the topic of http response codes, thus spake MDN:

From 9ae8918156099af9dcc15954514b9d43e1a7a047 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= <jordigh@octave.org>
Date: Mon, 22 Jul 2024 10:00:07 -0400
Subject: [PATCH 092/145] supervisor: remove config stub

The configuration is now handled by the config API, so we no longer
need the stub function here.
---
 sandbox/supervisor.mjs | 9 +--------
 1 file changed, 1 insertion(+), 8 deletions(-)

diff --git a/sandbox/supervisor.mjs b/sandbox/supervisor.mjs
index 2508cb17..832d42df 100644
--- a/sandbox/supervisor.mjs
+++ b/sandbox/supervisor.mjs
@@ -3,14 +3,13 @@ import {spawn} from 'child_process';
 let grist;
 
 function startGrist(newConfig={}) {
-  saveNewConfig(newConfig);
   // H/T https://stackoverflow.com/a/36995148/11352427
   grist = spawn('./sandbox/run.sh', {
     stdio: ['inherit', 'inherit', 'inherit', 'ipc']
   });
   grist.on('message', function(data) {
     if (data.action === 'restart') {
-      console.log('Restarting Grist with new environment');
+      console.log('Restarting Grist with new configuration');
 
       // Note that we only set this event handler here, after we have
       // a new environment to reload with. Small chance of a race here
@@ -26,10 +25,4 @@ function startGrist(newConfig={}) {
   return grist;
 }
 
-// Stub function
-function saveNewConfig(newConfig) {
-  // TODO: something here to actually persist the new config before
-  // restarting Grist.
-}
-
 startGrist();

From 960f0236186d311a9651eae0f87a5115b865b658 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= <jordigh@octave.org>
Date: Mon, 29 Jul 2024 16:28:55 -0400
Subject: [PATCH 093/145] restart: gracefully handle restart failure

In case Grist isn't running with the supervisor (e.g. it's running
under nodemon instead via `yarn start`), surface the problem to the
frontend.
---
 app/server/lib/FlexServer.ts | 13 +++++++++----
 sandbox/supervisor.mjs       |  3 ++-
 2 files changed, 11 insertions(+), 5 deletions(-)

diff --git a/app/server/lib/FlexServer.ts b/app/server/lib/FlexServer.ts
index 15429b2d..240d8195 100644
--- a/app/server/lib/FlexServer.ts
+++ b/app/server/lib/FlexServer.ts
@@ -1886,10 +1886,15 @@ export class FlexServer implements GristServer {
           process.send({ action: 'restart' });
         }
       });
-      // On the topic of http response codes, thus spake MDN:
-      // "409: This response is sent when a request conflicts with the current state of the server."
-      const status = process.send ? 200 : 409;
-      return resp.status(status).send();
+
+      if(!process.env.GRIST_RUNNING_UNDER_SUPERVISOR) {
+        // On the topic of http response codes, thus spake MDN:
+        // "409: This response is sent when a request conflicts with the current state of the server."
+        return resp.status(409).send({
+          error: "Cannot automatically restart the Grist server to enact changes. Please restart server manually."
+        });
+      }
+      return resp.status(200).send({ msg: 'ok' });
     }));
 
     // Restrict this endpoint to install admins
diff --git a/sandbox/supervisor.mjs b/sandbox/supervisor.mjs
index 832d42df..f7178f3c 100644
--- a/sandbox/supervisor.mjs
+++ b/sandbox/supervisor.mjs
@@ -5,7 +5,8 @@ let grist;
 function startGrist(newConfig={}) {
   // H/T https://stackoverflow.com/a/36995148/11352427
   grist = spawn('./sandbox/run.sh', {
-    stdio: ['inherit', 'inherit', 'inherit', 'ipc']
+    stdio: ['inherit', 'inherit', 'inherit', 'ipc'],
+    env: {...process.env, GRIST_RUNNING_UNDER_SUPERVISOR: true}
   });
   grist.on('message', function(data) {
     if (data.action === 'restart') {

From 62a04e9510e65ca554133433caac3dd2192dbbc9 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= <jordigh@octave.org>
Date: Mon, 22 Jul 2024 10:01:57 -0400
Subject: [PATCH 094/145] ConfigAPI: new class to handle frontend requests to
 config backend

This new API is somewhat patterned after the InstallAPI, but simpler
whenever possible.
---
 app/common/ConfigAPI.ts | 31 +++++++++++++++++++++++++++++++
 1 file changed, 31 insertions(+)
 create mode 100644 app/common/ConfigAPI.ts

diff --git a/app/common/ConfigAPI.ts b/app/common/ConfigAPI.ts
new file mode 100644
index 00000000..a8a12e83
--- /dev/null
+++ b/app/common/ConfigAPI.ts
@@ -0,0 +1,31 @@
+import {BaseAPI, IOptions} from "app/common/BaseAPI";
+import {addCurrentOrgToPath} from 'app/common/urlUtils';
+
+/**
+ * An API for accessing the internal Grist configuration, stored in
+ * config.json.
+ */
+export class ConfigAPI extends BaseAPI {
+  constructor(private _homeUrl: string, options: IOptions = {}) {
+    super(options);
+  }
+
+  public async getValue(key: string): Promise<any> {
+    return (await this.requestJson(`${this._url}/api/config/${key}`, {method: 'GET'})).value;
+  }
+
+  public async setValue(value: any, restart=false): Promise<void> {
+    await this.request(`${this._url}/api/config`, {
+      method: 'PATCH',
+      body: JSON.stringify({config: value, restart}),
+    });
+  }
+
+  public async restartServer(): Promise<void> {
+    await this.request(`${this._url}/api/admin/restart`, {method: 'POST'});
+  }
+
+  private get _url(): string {
+    return addCurrentOrgToPath(this._homeUrl);
+  }
+}

From f0cf86be8e1be677e59912c1e80bc99847d47f43 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= <jordigh@octave.org>
Date: Mon, 22 Jul 2024 10:17:32 -0400
Subject: [PATCH 095/145] ToggleEnterpriseModel: new GrainJS model to handle
 changes to config API

Patterned after TelemetryModel.ts
---
 app/client/models/ToggleEnterpriseModel.ts | 44 ++++++++++++++++++++++
 1 file changed, 44 insertions(+)
 create mode 100644 app/client/models/ToggleEnterpriseModel.ts

diff --git a/app/client/models/ToggleEnterpriseModel.ts b/app/client/models/ToggleEnterpriseModel.ts
new file mode 100644
index 00000000..3c23d248
--- /dev/null
+++ b/app/client/models/ToggleEnterpriseModel.ts
@@ -0,0 +1,44 @@
+import {getHomeUrl} from 'app/client/models/AppModel';
+import {Disposable, Observable} from "grainjs";
+import {ConfigAPI} from 'app/common/ConfigAPI';
+import {delay} from 'app/common/delay';
+
+export class ToggleEnterpriseModel extends Disposable {
+  public readonly edition: Observable<string | null> = Observable.create(this, null);
+  private readonly _configAPI: ConfigAPI = new ConfigAPI(getHomeUrl());
+
+  public async fetchEnterpriseToggle(): Promise<void> {
+    const edition = await this._configAPI.getValue('edition');
+    this.edition.set(edition);
+  }
+
+  public async updateEnterpriseToggle(edition: string): Promise<void> {
+    // We may be restarting the server, so these requests may well
+    // fail if done in quick succession.
+    await retryOnNetworkError(() => this._configAPI.setValue({edition}));
+    this.edition.set(edition);
+    await retryOnNetworkError(() => this._configAPI.restartServer());
+  }
+}
+
+// Copied from DocPageModel.ts
+const reconnectIntervals = [1000, 1000, 2000, 5000, 10000];
+async function retryOnNetworkError<R>(func: () => Promise<R>): Promise<R> {
+  for (let attempt = 0; ; attempt++) {
+    try {
+      return await func();
+    } catch (err) {
+      // fetch() promises that network errors are reported as TypeError. We'll accept NetworkError too.
+      if (err.name !== "TypeError" && err.name !== "NetworkError") {
+        throw err;
+      }
+      // We really can't reach the server. Make it known.
+      if (attempt >= reconnectIntervals.length) {
+        throw err;
+      }
+      const reconnectTimeout = reconnectIntervals[attempt];
+      console.warn(`Call to ${func.name} failed, will retry in ${reconnectTimeout} ms`, err);
+      await delay(reconnectTimeout);
+    }
+  }
+}

From 4621b67c8e88381ed0654e9453971f213c3d62d1 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= <jordigh@octave.org>
Date: Sun, 28 Jul 2024 19:08:33 -0400
Subject: [PATCH 096/145] AdminToggleCss: factor out CSS from SupportGristPage

We will create a new enterprise toggle, so we will need to share the same CSS.
---
 app/client/ui/AdminTogglesCss.ts  | 45 ++++++++++++++++++++++++
 app/client/ui/SupportGristPage.ts | 58 +++++++------------------------
 2 files changed, 58 insertions(+), 45 deletions(-)
 create mode 100644 app/client/ui/AdminTogglesCss.ts

diff --git a/app/client/ui/AdminTogglesCss.ts b/app/client/ui/AdminTogglesCss.ts
new file mode 100644
index 00000000..5419d428
--- /dev/null
+++ b/app/client/ui/AdminTogglesCss.ts
@@ -0,0 +1,45 @@
+import {bigBasicButton, bigBasicButtonLink, bigPrimaryButton} from 'app/client/ui2018/buttons';
+import {theme} from 'app/client/ui2018/cssVars';
+import {styled} from 'grainjs';
+
+export const cssSection = styled('div', ``);
+
+export const cssParagraph = styled('div', `
+  color: ${theme.text};
+  font-size: 14px;
+  line-height: 20px;
+  margin-bottom: 12px;
+`);
+
+export const cssOptInOutMessage = styled(cssParagraph, `
+  line-height: 40px;
+  font-weight: 600;
+  margin-top: 24px;
+  margin-bottom: 0px;
+`);
+
+export const cssOptInButton = styled(bigPrimaryButton, `
+  margin-top: 24px;
+`);
+
+export const cssOptOutButton = styled(bigBasicButton, `
+  margin-top: 24px;
+`);
+
+export const cssSponsorButton = styled(bigBasicButtonLink, `
+  margin-top: 24px;
+`);
+
+export const cssButtonIconAndText = styled('div', `
+  display: flex;
+  align-items: center;
+`);
+
+export const cssButtonText = styled('span', `
+  margin-left: 8px;
+`);
+
+export const cssSpinnerBox = styled('div', `
+  margin-top: 24px;
+  text-align: center;
+`);
diff --git a/app/client/ui/SupportGristPage.ts b/app/client/ui/SupportGristPage.ts
index 306ef858..13e0ed8f 100644
--- a/app/client/ui/SupportGristPage.ts
+++ b/app/client/ui/SupportGristPage.ts
@@ -1,14 +1,24 @@
 import {makeT} from 'app/client/lib/localization';
 import {AppModel} from 'app/client/models/AppModel';
 import {TelemetryModel, TelemetryModelImpl} from 'app/client/models/TelemetryModel';
-import {basicButtonLink, bigBasicButton, bigBasicButtonLink, bigPrimaryButton} from 'app/client/ui2018/buttons';
-import {theme} from 'app/client/ui2018/cssVars';
+import {
+  cssButtonIconAndText,
+  cssButtonText,
+  cssOptInButton,
+  cssOptInOutMessage,
+  cssOptOutButton,
+  cssParagraph,
+  cssSection,
+  cssSpinnerBox,
+  cssSponsorButton,
+} from 'app/client/ui/AdminTogglesCss';
+import {basicButtonLink} from 'app/client/ui2018/buttons';
 import {icon} from 'app/client/ui2018/icons';
 import {cssLink} from 'app/client/ui2018/links';
 import {loadingSpinner} from 'app/client/ui2018/loaders';
 import {commonUrls} from 'app/common/gristUrls';
 import {TelemetryPrefsWithSources} from 'app/common/InstallAPI';
-import {Computed, Disposable, dom, makeTestId, styled} from 'grainjs';
+import {Computed, Disposable, dom, makeTestId} from 'grainjs';
 
 const testId = makeTestId('test-support-grist-page-');
 
@@ -164,45 +174,3 @@ function gristCoreLink() {
     {href: commonUrls.githubGristCore, target: '_blank'},
   );
 }
-
-const cssSection = styled('div', ``);
-
-const cssParagraph = styled('div', `
-  color: ${theme.text};
-  font-size: 14px;
-  line-height: 20px;
-  margin-bottom: 12px;
-`);
-
-const cssOptInOutMessage = styled(cssParagraph, `
-  line-height: 40px;
-  font-weight: 600;
-  margin-top: 24px;
-  margin-bottom: 0px;
-`);
-
-const cssOptInButton = styled(bigPrimaryButton, `
-  margin-top: 24px;
-`);
-
-const cssOptOutButton = styled(bigBasicButton, `
-  margin-top: 24px;
-`);
-
-const cssSponsorButton = styled(bigBasicButtonLink, `
-  margin-top: 24px;
-`);
-
-const cssButtonIconAndText = styled('div', `
-  display: flex;
-  align-items: center;
-`);
-
-const cssButtonText = styled('span', `
-  margin-left: 8px;
-`);
-
-const cssSpinnerBox = styled('div', `
-  margin-top: 24px;
-  text-align: center;
-`);

From 0bf3f9bc432973fd567b3d5d0fb49f73a0fb47ea Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= <jordigh@octave.org>
Date: Mon, 29 Jul 2024 19:13:20 -0400
Subject: [PATCH 097/145] markdown: new utility module

Since we've started using Markdown, why not a simple utility function
to start using it?
---
 app/client/lib/markdown.ts | 11 +++++++++++
 1 file changed, 11 insertions(+)
 create mode 100644 app/client/lib/markdown.ts

diff --git a/app/client/lib/markdown.ts b/app/client/lib/markdown.ts
new file mode 100644
index 00000000..49365b08
--- /dev/null
+++ b/app/client/lib/markdown.ts
@@ -0,0 +1,11 @@
+import { sanitizeHTML } from 'app/client/ui/sanitizeHTML';
+import { BindableValue, DomElementMethod, subscribeElem } from 'grainjs';
+import { marked } from 'marked';
+
+export function markdown(markdownObs: BindableValue<string>): DomElementMethod {
+  return elem => subscribeElem(elem, markdownObs, value => setMarkdownValue(elem, value));
+}
+
+function setMarkdownValue(elem: Element, markdownValue: string): void {
+  elem.innerHTML = sanitizeHTML(marked(markdownValue));
+}

From ffe3b2237865bed1d0833915750794a31953c3f1 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= <jordigh@octave.org>
Date: Wed, 24 Jul 2024 18:55:19 -0400
Subject: [PATCH 098/145] ToggleEnterpriseWidget: new frontend toggle for the
 admin

Strongly patterned after SupportGristPage. In fact, it has almost the
same structure.

Perhaps one day it would be possible to synchronise the logic between
the two toggles even further, but I couldn't see a simple way to do so
now. For now, some code structure duplication seemed easiest in lieau
of more abstractions.
---
 app/client/ui/ToggleEnterpriseWidget.ts | 78 +++++++++++++++++++++++++
 app/common/gristUrls.ts                 |  1 +
 2 files changed, 79 insertions(+)
 create mode 100644 app/client/ui/ToggleEnterpriseWidget.ts

diff --git a/app/client/ui/ToggleEnterpriseWidget.ts b/app/client/ui/ToggleEnterpriseWidget.ts
new file mode 100644
index 00000000..01019900
--- /dev/null
+++ b/app/client/ui/ToggleEnterpriseWidget.ts
@@ -0,0 +1,78 @@
+import {makeT} from 'app/client/lib/localization';
+import {markdown} from 'app/client/lib/markdown';
+import {Computed, Disposable, dom, makeTestId} from "grainjs";
+import {commonUrls} from "app/common/gristUrls";
+import {ToggleEnterpriseModel} from 'app/client/models/ToggleEnterpriseModel';
+import {
+  cssOptInButton,
+  cssOptOutButton,
+  cssParagraph,
+  cssSection,
+} from 'app/client/ui/AdminTogglesCss';
+
+
+const t = makeT('ToggleEnterprsiePage');
+const testId = makeTestId('test-toggle-enterprise-page-');
+
+export class ToggleEnterpriseWidget extends Disposable {
+  private readonly _model: ToggleEnterpriseModel = new ToggleEnterpriseModel();
+  private readonly _isEnterprise = Computed.create(this, this._model.edition, (_use, edition) => {
+    return edition === 'enterprise';
+  }).onWrite(async (enabled) => {
+    await this._model.updateEnterpriseToggle(enabled ? 'enterprise' : 'core');
+  });
+
+  constructor() {
+    super();
+    this._model.fetchEnterpriseToggle().catch(reportError);
+  }
+
+  public getEnterpriseToggleObservable() {
+    return this._isEnterprise;
+  }
+
+  public buildEnterpriseSection() {
+    return cssSection(
+      dom.domComputed(this._isEnterprise, (enterpriseEnabled) => {
+        return [
+          enterpriseEnabled ?
+            cssParagraph(
+              markdown(t('Grist Enterprise is **enabled**.')),
+              testId('enterprise-opt-out-message'),
+            ) : null,
+          cssParagraph(
+            markdown(t(`An activation key is used to run Grist Enterprise after a trial period
+of 30 days has expired. Get an activation key by [signing up for Grist
+Enterprise]({{signupLink}}). You do not need an activation key to run
+Grist Core.
+
+Learn more in our [Help Center]({{helpCenter}}).`, {
+                signupLink: commonUrls.plans,
+                helpCenter: commonUrls.helpEnterpriseOptIn
+            }))
+          ),
+          this._buildEnterpriseSectionButtons(),
+        ];
+      }),
+      testId('enterprise-opt-in-section'),
+    );
+  }
+
+  public _buildEnterpriseSectionButtons() {
+    return dom.domComputed(this._isEnterprise, (enterpriseEnabled) => {
+      if (enterpriseEnabled) {
+        return [
+          cssOptOutButton(t('Disable Grist Enterprise'),
+            dom.on('click', () => this._isEnterprise.set(false)),
+          ),
+        ];
+      } else {
+        return [
+          cssOptInButton(t('Enable Grist Enterprise'),
+            dom.on('click', () => this._isEnterprise.set(true)),
+          ),
+        ];
+      }
+    });
+  }
+}
diff --git a/app/common/gristUrls.ts b/app/common/gristUrls.ts
index 2f81d215..6edc6fa8 100644
--- a/app/common/gristUrls.ts
+++ b/app/common/gristUrls.ts
@@ -84,6 +84,7 @@ export const commonUrls = {
   helpTryingOutChanges: "https://support.getgrist.com/copying-docs/#trying-out-changes",
   helpCustomWidgets: "https://support.getgrist.com/widget-custom",
   helpTelemetryLimited: "https://support.getgrist.com/telemetry-limited",
+  helpEnterpriseOptIn: "https://support.getgrist.com/self-managed/#how-do-i-activate-grist-enterprise",
   helpCalendarWidget: "https://support.getgrist.com/widget-calendar",
   helpLinkKeys: "https://support.getgrist.com/examples/2021-04-link-keys",
   helpFilteringReferenceChoices: "https://support.getgrist.com/col-refs/#filtering-reference-choices-in-dropdown",

From 1b6a80335fe7b529e31d48e86abdc57da9d1db86 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= <jordigh@octave.org>
Date: Tue, 30 Jul 2024 12:52:24 -0400
Subject: [PATCH 099/145] AdminPanel: add the toggle for enterprise

Final ingredient. This surfaces the work in creating the backend
config API, the frontend model, the grainjs observable, and the
grainjs DOM and CSS components.
---
 app/client/ui/AdminPanel.ts | 10 +++++++++-
 1 file changed, 9 insertions(+), 1 deletion(-)

diff --git a/app/client/ui/AdminPanel.ts b/app/client/ui/AdminPanel.ts
index 91f6802b..c0bb0682 100644
--- a/app/client/ui/AdminPanel.ts
+++ b/app/client/ui/AdminPanel.ts
@@ -9,6 +9,7 @@ import {AppHeader} from 'app/client/ui/AppHeader';
 import {leftPanelBasic} from 'app/client/ui/LeftPanelCommon';
 import {pagePanels} from 'app/client/ui/PagePanels';
 import {SupportGristPage} from 'app/client/ui/SupportGristPage';
+import {ToggleEnterpriseWidget} from 'app/client/ui/ToggleEnterpriseWidget';
 import {createTopBarHome} from 'app/client/ui/TopBar';
 import {cssBreadcrumbs, separator} from 'app/client/ui2018/breadcrumbs';
 import {basicButton} from 'app/client/ui2018/buttons';
@@ -25,7 +26,6 @@ import {Computed, Disposable, dom, IDisposable,
         IDisposableOwner, MultiHolder, Observable, styled} from 'grainjs';
 import {AdminSection, AdminSectionItem, HidableToggle} from 'app/client/ui/AdminPanelCss';
 
-
 const t = makeT('AdminPanel');
 
 // Translated "Admin Panel" name, made available to other modules.
@@ -35,6 +35,7 @@ export function getAdminPanelName() {
 
 export class AdminPanel extends Disposable {
   private _supportGrist = SupportGristPage.create(this, this._appModel);
+  private _toggleEnterprise = ToggleEnterpriseWidget.create(this);
   private readonly _installAPI: InstallAPI = new InstallAPIImpl(getHomeUrl());
   private _checks: AdminChecks;
 
@@ -161,6 +162,13 @@ Please log in as an administrator.`)),
           description: t('Current version of Grist'),
           value: cssValueLabel(`Version ${version.version}`),
         }),
+        dom.create(AdminSectionItem, {
+          id: 'enterprise',
+          name: t('Enterprise'),
+          description: t('Enable Grist Enterprise'),
+          value: dom.create(HidableToggle, this._toggleEnterprise.getEnterpriseToggleObservable()),
+          expandedContent: this._toggleEnterprise.buildEnterpriseSection(),
+        }),
         this._buildUpdates(owner),
       ]),
       dom.create(AdminSection, t('Self Checks'), [

From 19d877f4fbdbe9be2c3ac7e189fe503702298b02 Mon Sep 17 00:00:00 2001
From: Camille L <camille.legeron@beta.gouv.fr>
Date: Tue, 30 Jul 2024 11:14:00 +0000
Subject: [PATCH 100/145] Translated using Weblate (French)

Currently translated at 99.7% (1364 of 1367 strings)

Translation: Grist/client
Translate-URL: https://hosted.weblate.org/projects/grist/client/fr/
---
 static/locales/fr.client.json | 34 +++++++++++++++++++++++++++++++++-
 1 file changed, 33 insertions(+), 1 deletion(-)

diff --git a/static/locales/fr.client.json b/static/locales/fr.client.json
index 463acae1..4d70bc16 100644
--- a/static/locales/fr.client.json
+++ b/static/locales/fr.client.json
@@ -1234,7 +1234,7 @@
         "Event Types": "Types d'événements",
         "Memo": "Mémo",
         "Ready Column": "Colonne de déclenchement",
-        "Removed webhook.": "Suppression du webhook.",
+        "Removed webhook.": "Point d'ancrage supprimé.",
         "Filter for changes in these columns (semicolon-separated ids)": "Filtrer les changements dans ces colonnes (identifiants séparés par des points-virgules)",
         "Status": "Statut",
         "URL": "URL",
@@ -1633,5 +1633,37 @@
         "Number of Calls": "Nombre d'appels",
         "Table ID": "ID de la table",
         "Total Time (s)": "Temps total"
+    },
+    "DocTutorial": {
+        "Next": "Suivant",
+        "Restart": "Redémarrer",
+        "Click to expand": "Cliquez pour agrandir",
+        "End tutorial": "Fin du tutoriel",
+        "Do you want to restart the tutorial? All progress will be lost.": "Voulez-vous recommencer le tutoriel ? Tous les progrès seront perdus.",
+        "Previous": "Précédent",
+        "Finish": "Finir"
+    },
+    "OnboardingCards": {
+        "Complete our basics tutorial": "Complétez notre tutoriel des bases",
+        "Learn the basic of reference columns, linked widgets, column types, & cards.": "Apprenez les bases des colonnes de référence, des vues liées, des types de colonnes et des fiches.",
+        "3 minute video tour": "Visite guidée vidéo de 3 minutes",
+        "Complete the tutorial": "Compléter le tutoriel"
+    },
+    "OnboardingPage": {
+        "Back": "Retour",
+        "Discover Grist in 3 minutes": "Découvrez Grist en 3 minutes",
+        "Go hands-on with the Grist Basics tutorial": "Mettez la main à la pâte avec le tutoriel des bases Grist",
+        "Type here": "Tapez ici",
+        "Welcome": "Bienvenue",
+        "What organization are you with?": "Quelle est l'organisation à laquelle vous appartenez ?",
+        "Your organization": "Votre organisation",
+        "Your role": "Votre rôle",
+        "Next step": "Prochaine étape",
+        "Skip step": "Sauter cette étape",
+        "Skip tutorial": "Sauter le tutoriel",
+        "Go to the tutorial!": "Allez au tutoriel !",
+        "Tell us who you are": "Dites-nous qui vous êtes",
+        "What brings you to Grist (you can select multiple)?": "Qu'est-ce qui vous amène à Grist (vous pouvez en sélectionner plusieurs) ?",
+        "What is your role?": "Quel est votre rôle ?"
     }
 }

From 7037c36cc90537c0e8d0df9b2be211638e8429ce Mon Sep 17 00:00:00 2001
From: gallegonovato <fran-carro@hotmail.es>
Date: Tue, 30 Jul 2024 08:40:42 +0000
Subject: [PATCH 101/145] Translated using Weblate (Spanish)

Currently translated at 100.0% (1367 of 1367 strings)

Translation: Grist/client
Translate-URL: https://hosted.weblate.org/projects/grist/client/es/
---
 static/locales/es.client.json | 32 ++++++++++++++++++++++++++++++++
 1 file changed, 32 insertions(+)

diff --git a/static/locales/es.client.json b/static/locales/es.client.json
index 20c36d64..aea535f6 100644
--- a/static/locales/es.client.json
+++ b/static/locales/es.client.json
@@ -1687,5 +1687,37 @@
         "Total Time (s)": "Tiempo total (s)",
         "Column ID": "ID de la columna",
         "Max Time (s)": "Tiempo máximo (s)"
+    },
+    "DocTutorial": {
+        "Click to expand": "Haz clic para ampliar",
+        "End tutorial": "Fin del tutorial",
+        "Next": "Siguiente",
+        "Restart": "Reanudar",
+        "Do you want to restart the tutorial? All progress will be lost.": "¿Quieres reiniciar el tutorial? Se perderá todo el progreso.",
+        "Finish": "Finalizar",
+        "Previous": "Anterior"
+    },
+    "OnboardingCards": {
+        "Complete our basics tutorial": "Completa nuestro tutorial básico",
+        "Complete the tutorial": "Completa el tutorial",
+        "3 minute video tour": "La duración del vídeo es de 3 minutos",
+        "Learn the basic of reference columns, linked widgets, column types, & cards.": "Aprende los conceptos básicos de las columnas de referencia, widgets vinculados, tipos de columnas y tarjetas."
+    },
+    "OnboardingPage": {
+        "Discover Grist in 3 minutes": "Descubre Grist en 3 minutos",
+        "Next step": "Paso siguiente",
+        "Skip step": "Omitir el paso",
+        "Skip tutorial": "Omitir el tutorial",
+        "Tell us who you are": "¿Quién eres?",
+        "Type here": "Escribe aquí",
+        "Welcome": "Bienvenid@",
+        "What brings you to Grist (you can select multiple)?": "¿Qué te trae a Grist (puedes seleccionar varias opciones)?",
+        "What is your role?": "¿Cual es tu papel?",
+        "Your role": "Tu función",
+        "Back": "Atrás",
+        "Your organization": "Tu organización",
+        "Go hands-on with the Grist Basics tutorial": "Práctica con el tutorial básico de Grist",
+        "Go to the tutorial!": "¡Ve al tutorial!",
+        "What organization are you with?": "¿A qué organización perteneces?"
     }
 }

From f2d3cff7e4d7da95b92e2f228a9cc8e5777b9f5c Mon Sep 17 00:00:00 2001
From: xabirequejo <xabi.rn@gmail.com>
Date: Tue, 30 Jul 2024 12:34:53 +0000
Subject: [PATCH 102/145] Translated using Weblate (Basque)

Currently translated at 99.8% (1365 of 1367 strings)

Translation: Grist/client
Translate-URL: https://hosted.weblate.org/projects/grist/client/eu/
---
 static/locales/eu.client.json | 50 ++++++++++++++++++++++++++++-------
 1 file changed, 41 insertions(+), 9 deletions(-)

diff --git a/static/locales/eu.client.json b/static/locales/eu.client.json
index f849bd0f..c968a438 100644
--- a/static/locales/eu.client.json
+++ b/static/locales/eu.client.json
@@ -166,7 +166,7 @@
         "Filter by Range": "Iragazi tartearen arabera",
         "Max": "Max.",
         "Start": "Hasi",
-        "End": "Amaitu",
+        "End": "Amaiera",
         "Other Values": "Beste balio batzuk",
         "All Except": "Denak, hauek izan ezik",
         "Other Non-Matching": "Bat ez datorren beste bat",
@@ -1186,7 +1186,7 @@
         "Update existing records": "Eguneratu lehendik dauden erregistroak",
         "{{count}} unmatched field_other": "Bat ez datozen {{count}} eremu",
         "Column Mapping": "Zutabeen mapaketa",
-        "Column mapping": "Zutabeen mapaketa",
+        "Column mapping": "Zutabeen esleipena",
         "{{count}} unmatched field in import_one": "Bat ez datorren eremu {{count}} inportazioan",
         "{{count}} unmatched field in import_other": "Bat ez datozen {{count}} eremu inportazioan",
         "{{count}} unmatched field_one": "Bat ez datorren eremu {{count}}",
@@ -1416,7 +1416,7 @@
         "Paragraph": "Paragrafoa",
         "Paste": "Itsatsi",
         "Separator": "Bereizgailua",
-        "Unmapped fields": "Mapaketatu gabeko eremuak",
+        "Unmapped fields": "Esleitu gabeko eremuak",
         "Header": "Goiburua"
     },
     "UnmappedFieldsConfig": {
@@ -1424,8 +1424,8 @@
         "Select All": "Hautatu guztia",
         "Map fields": "Mapaketatu eremuak",
         "Mapped": "Mapaketatuta",
-        "Unmap fields": "Desmapaketatu eremuak",
-        "Unmapped": "Mapaketatu gabe"
+        "Unmap fields": "Utzi eremuak esleitzeari",
+        "Unmapped": "Esleitu gabe"
     },
     "FormConfig": {
         "Default": "Defektuzkoa",
@@ -1471,8 +1471,8 @@
         "Map fields": "Mapaketatu eremuak",
         "Mapped": "Mapaketatuta",
         "Select All": "Hautatu guztia",
-        "Unmapped": "Mapaketatu gabe",
-        "Unmap fields": "Desmapaketatu eremuak"
+        "Unmapped": "Esleitu gabe",
+        "Unmap fields": "Utzi eremuak esleitzeari"
     },
     "CreateTeamModal": {
         "Cancel": "Utzi",
@@ -1609,8 +1609,8 @@
         "Hidden fields": "Ezkutatutako eremuak"
     },
     "CustomView": {
-        "Some required columns aren't mapped": "Nahitaezko zutabe batzuk ez daude mapaketatuta",
-        "To use this widget, please map all non-optional columns from the creator panel on the right.": "Widget hau erabiltzeko, mapaketatu aukerakoak ez diren zutabeak sortzaileen mahaigainetik, eskuinean."
+        "Some required columns aren't mapped": "Nahitaezko zutabe batzuk ez daude esleituta",
+        "To use this widget, please map all non-optional columns from the creator panel on the right.": "Widget hau erabiltzeko, esleitu aukerakoak ez diren zutabeak sortzaileen mahaigainetik, eskuinean."
     },
     "FormContainer": {
         "Build your own form": "Sortu zure formularioa",
@@ -1633,5 +1633,37 @@
     },
     "DropdownConditionEditor": {
         "Enter condition.": "Sartu baldintza."
+    },
+    "DocTutorial": {
+        "Click to expand": "Egin klik hedatzeko",
+        "Do you want to restart the tutorial? All progress will be lost.": "Berriz hasi nahi duzu tutoriala? Orain arte egindakoa galduko da.",
+        "Next": "Hurrengoa",
+        "Previous": "Aurrekoa",
+        "Restart": "Hasi berriz",
+        "End tutorial": "Tutorialaren amaiera",
+        "Finish": "Amaitu"
+    },
+    "OnboardingCards": {
+        "3 minute video tour": "3 minutuko bideo-bisitaldia",
+        "Complete our basics tutorial": "Burutu oinarrizko tutoriala",
+        "Complete the tutorial": "Burutu tutoriala",
+        "Learn the basic of reference columns, linked widgets, column types, & cards.": "Ikasi erreferentzia-zutabeen, lotutako widgeten, zutabe-moten eta txartelen oinarriak."
+    },
+    "OnboardingPage": {
+        "Go hands-on with the Grist Basics tutorial": "Murgildu Gristen oinarrizko tutorialean",
+        "Next step": "Hurrengo urratsa",
+        "Skip step": "Utzi egin gabe",
+        "Skip tutorial": "Utzi tutoriala egin gabe",
+        "Welcome": "Ongi etorri",
+        "What brings you to Grist (you can select multiple)?": "Zerk zakartza Gristera? (bat baino gehiago hautatu dezakezu)?",
+        "Back": "Atzera",
+        "Tell us who you are": "Esaguzu nor zaren",
+        "Discover Grist in 3 minutes": "Ezagutu Grist 3 minututan",
+        "Go to the tutorial!": "Joan tutorialera!",
+        "Type here": "Idatzi hemen",
+        "What is your role?": "Zein da zure rola?",
+        "What organization are you with?": "Zein erakunderekin jarduten duzu?",
+        "Your role": "Zure rola",
+        "Your organization": "Zure erakundea"
     }
 }

From 07eae477e2258380c8116c9c9fb108f4f36084b9 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
 <41898282+github-actions[bot]@users.noreply.github.com>
Date: Wed, 31 Jul 2024 09:22:57 -0400
Subject: [PATCH 103/145] automated update to translation keys (#1132)

Co-authored-by: Paul's Grist Bot <paul+bot@getgrist.com>
---
 static/locales/en.client.json | 17 ++++++++++++++++-
 1 file changed, 16 insertions(+), 1 deletion(-)

diff --git a/static/locales/en.client.json b/static/locales/en.client.json
index 3df3d24f..4b88c5e1 100644
--- a/static/locales/en.client.json
+++ b/static/locales/en.client.json
@@ -1572,7 +1572,9 @@
         "Grist allows different types of authentication to be configured, including SAML and OIDC. We recommend enabling one of these if Grist is accessible over the network or being made available to multiple people.": "Grist allows different types of authentication to be configured, including SAML and OIDC. We recommend enabling one of these if Grist is accessible over the network or being made available to multiple people.",
         "Grist signs user session cookies with a secret key. Please set this key via the environment variable GRIST_SESSION_SECRET. Grist falls back to a hard-coded default when it is not set. We may remove this notice in the future as session IDs generated since v1.1.16 are inherently cryptographically secure.": "Grist signs user session cookies with a secret key. Please set this key via the environment variable GRIST_SESSION_SECRET. Grist falls back to a hard-coded default when it is not set. We may remove this notice in the future as session IDs generated since v1.1.16 are inherently cryptographically secure.",
         "Key to sign sessions with": "Key to sign sessions with",
-        "Session Secret": "Session Secret"
+        "Session Secret": "Session Secret",
+        "Enable Grist Enterprise": "Enable Grist Enterprise",
+        "Enterprise": "Enterprise"
     },
     "Columns": {
         "Remove Column": "Remove Column"
@@ -1665,5 +1667,18 @@
         "What organization are you with?": "What organization are you with?",
         "Your organization": "Your organization",
         "Your role": "Your role"
+    },
+    "ToggleEnterpriseWidget": {
+        "An activation key is used to run Grist Enterprise after a trial period\nof 30 days has expired. Get an activation key by [signing up for Grist\nEnterprise]({{signupLink}}). You do not need an activation key to run\nGrist Core.\n\nLearn more in our [Help Center]({{helpCenter}}).": "An activation key is used to run Grist Enterprise after a trial period\nof 30 days has expired. Get an activation key by [signing up for Grist\nEnterprise]({{signupLink}}). You do not need an activation key to run\nGrist Core.\n\nLearn more in our [Help Center]({{helpCenter}}).",
+        "Disable Grist Enterprise": "Disable Grist Enterprise",
+        "Enable Grist Enterprise": "Enable Grist Enterprise",
+        "Grist Enterprise is **enabled**.": "Grist Enterprise is **enabled**."
+    },
+    "ViewLayout": {
+        "Delete": "Delete",
+        "Delete data and this widget.": "Delete data and this widget.",
+        "Keep data and delete widget. Table will remain available in {{rawDataLink}}": "Keep data and delete widget. Table will remain available in {{rawDataLink}}",
+        "Table {{tableName}} will no longer be visible": "Table {{tableName}} will no longer be visible",
+        "raw data page": "raw data page"
     }
 }

From 42f696d50f01d35b43b413295a2977497c94480d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= <jordigh@octave.org>
Date: Wed, 31 Jul 2024 09:19:56 -0400
Subject: [PATCH 104/145] README: update method to toggle enterprise

---
 README.md | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/README.md b/README.md
index e2ba658a..401baf37 100644
--- a/README.md
+++ b/README.md
@@ -125,9 +125,9 @@ the standard Grist functionality, as well as extra source-available
 code for enterprise customers taken from the
 [grist-ee](https://github.com/gristlabs/grist-ee) repository. This
 extra code is not under a free or open source license. By default,
-however, the code from the `grist-ee` repository is completely inert and
-inactive. This code becomes active only when an administrator enables
-it by setting either `GRIST_ACTIVATION` or `GRIST_ACTIVATION_FILE`.
+however, the code from the `grist-ee` repository is completely inert
+and inactive. This code becomes active only when enabled from the
+administrator panel.
 
 If you would rather use an image that contains exclusively free and
 open source code, the `gristlabs/grist-oss` Docker image is available

From edfe1f96300d5b001b2658564442ac3f426951e1 Mon Sep 17 00:00:00 2001
From: Roman Holinec <3ko@pixeon.sk>
Date: Fri, 2 Aug 2024 06:03:35 +0000
Subject: [PATCH 105/145] Translated using Weblate (Slovak)

Currently translated at 100.0% (1367 of 1367 strings)

Translation: Grist/client
Translate-URL: https://hosted.weblate.org/projects/grist/client/sk/
---
 static/locales/sk.client.json | 38 +++++++++++++++++++++++++++++++++--
 1 file changed, 36 insertions(+), 2 deletions(-)

diff --git a/static/locales/sk.client.json b/static/locales/sk.client.json
index 8dbd9f36..e3a9f146 100644
--- a/static/locales/sk.client.json
+++ b/static/locales/sk.client.json
@@ -517,7 +517,8 @@
         "Visit our {{link}} to learn more about Grist.": "Navštíviť náš {{link}} a dozvedieť sa viac o Grist.",
         "Learn more in our {{helpCenterLink}}, or find an expert via our {{sproutsProgram}}.": "Viac informácií nájdete v našom {{helpCenterLink}} alebo nájdite odborníka prostredníctvom nášho {{sproutsProgram}}.",
         "Interested in using Grist outside of your team? Visit your free ": "Máte záujem používať Grist mimo váš tím? Navštívte svoje bezplatné ",
-        "Sign up": "Prihlásiť sa"
+        "Sign up": "Prihlásiť sa",
+        "Learn more in our {{helpCenterLink}}.": "Zistiť viac v našom {{helpCenterLink}}."
     },
     "Importer": {
         "Merge rows that match these fields:": "Zlúčiť riadky, ktoré zodpovedajú týmto poliam:",
@@ -1117,7 +1118,8 @@
         "URL": "URL",
         "Webhook Id": "Webhook ID",
         "Table": "Tabuľka",
-        "Filter for changes in these columns (semicolon-separated ids)": "Filtrovať zmeny v týchto stĺpcoch (identifikátory oddelené bodkočiarkou)"
+        "Filter for changes in these columns (semicolon-separated ids)": "Filtrovať zmeny v týchto stĺpcoch (identifikátory oddelené bodkočiarkou)",
+        "Header Authorization": "Autorizácia Hlavičky"
     },
     "FormulaAssistant": {
         "Capabilities": "Schopnosti",
@@ -1632,5 +1634,37 @@
     },
     "Columns": {
         "Remove Column": "Odobrať stĺpec"
+    },
+    "DocTutorial": {
+        "End tutorial": "Ukončiť tutorial",
+        "Finish": "Dokončiť",
+        "Next": "Ďaľší",
+        "Previous": "Predošlý",
+        "Restart": "Reštart",
+        "Do you want to restart the tutorial? All progress will be lost.": "Chcete reštartovať výukový program? Všetok pokrok sa stratí.",
+        "Click to expand": "Kliknutím rozbaliť"
+    },
+    "OnboardingCards": {
+        "Complete our basics tutorial": "Náš kompletný základný návod",
+        "Complete the tutorial": "Dokončiť tutoriál",
+        "Learn the basic of reference columns, linked widgets, column types, & cards.": "Naučiť sa základ referenčných stĺpcov, prepojených miniaplikácií, typov stĺpcov a kariet.",
+        "3 minute video tour": "3 minútová videoprehliadka"
+    },
+    "OnboardingPage": {
+        "Back": "Späť",
+        "Discover Grist in 3 minutes": "Preskúmať Grist za 3 minúty",
+        "Go hands-on with the Grist Basics tutorial": "Praktický tutoriál Základy Gristu",
+        "Go to the tutorial!": "Prejsť na tutoriál!",
+        "Next step": "Ďalší krok",
+        "Skip step": "Prekočiť krok",
+        "Skip tutorial": "Preskočiť tutoriál",
+        "Tell us who you are": "Povedz nám, kto si",
+        "Welcome": "Vitajte",
+        "What brings you to Grist (you can select multiple)?": "Čo vás privádza do Gristu (môžete vybrať viacero)?",
+        "What is your role?": "Aká je vaša úloha?",
+        "What organization are you with?": "Aká je Vaša organizácia?",
+        "Type here": "Zadať sem",
+        "Your organization": "Vaša organizácia",
+        "Your role": "Vaša úloha"
     }
 }

From c205f4cfb19051b1b795ee75e958483f1f61f4fc Mon Sep 17 00:00:00 2001
From: xabirequejo <xabi.rn@gmail.com>
Date: Thu, 1 Aug 2024 13:10:15 +0000
Subject: [PATCH 106/145] Translated using Weblate (Basque)

Currently translated at 99.8% (1365 of 1367 strings)

Translation: Grist/client
Translate-URL: https://hosted.weblate.org/projects/grist/client/eu/
---
 static/locales/eu.client.json | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/static/locales/eu.client.json b/static/locales/eu.client.json
index c968a438..49bc7130 100644
--- a/static/locales/eu.client.json
+++ b/static/locales/eu.client.json
@@ -834,7 +834,7 @@
         "Your account has been deleted.": "Zure kontua ezabatu da.",
         "An unknown error occurred.": "Errore ezezagun bat gertatu da.",
         "Form not found": "Ez da formularioa aurkitu",
-        "Access denied{{suffix}}": "Sarbidea ukatua da{{suffix}}",
+        "Access denied{{suffix}}": "Sarbidea ukatu da{{suffix}}",
         "Error{{suffix}}": "Errorea{{suffix}}",
         "Page not found{{suffix}}": "Ez da orria aurkitu {{suffix}}",
         "Signed out{{suffix}}": "Saioa amaituta{{suffix}}",

From 5206663ee30efac7cdc6cc1d9b04692dc304efb6 Mon Sep 17 00:00:00 2001
From: Florent <florent.git@zeteo.me>
Date: Fri, 2 Aug 2024 17:33:58 +0200
Subject: [PATCH 107/145] Issues and PR templates (#1135)

Issues and PRs descriptions sometimes lacks of details we would like to have to better understand the motivations behind.

I propose here templates to guide the reporters towards good descriptions.

These templates are heavily inspired from the ones of the PeerTube project.

Fixes #1125

---------

Co-authored-by: CamilleLegeron <camille@telescoop.fr>
---
 .github/ISSUE_TEMPLATE/00-bug-issue.yml       | 52 +++++++++++++++++++
 .../ISSUE_TEMPLATE/10-installation-issue.yml  | 33 ++++++++++++
 .github/ISSUE_TEMPLATE/20-feature-request.yml | 23 ++++++++
 .github/ISSUE_TEMPLATE/config.yml             |  8 +++
 .github/PULL_REQUEST_TEMPLATE.md              | 27 ++++++++++
 5 files changed, 143 insertions(+)
 create mode 100644 .github/ISSUE_TEMPLATE/00-bug-issue.yml
 create mode 100644 .github/ISSUE_TEMPLATE/10-installation-issue.yml
 create mode 100644 .github/ISSUE_TEMPLATE/20-feature-request.yml
 create mode 100644 .github/ISSUE_TEMPLATE/config.yml
 create mode 100644 .github/PULL_REQUEST_TEMPLATE.md

diff --git a/.github/ISSUE_TEMPLATE/00-bug-issue.yml b/.github/ISSUE_TEMPLATE/00-bug-issue.yml
new file mode 100644
index 00000000..d2d861ab
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/00-bug-issue.yml
@@ -0,0 +1,52 @@
+# Inspired by PeerTube templates:
+# https://github.com/Chocobozzz/PeerTube/blob/3d4d49a23eae71f3ce62cbbd7d93f07336a106b7/.github/ISSUE_TEMPLATE/00-bug-issue.yml
+name: 🐛 Bug Report
+description: Use this template for reporting a bug
+body:
+  - type: markdown
+    attributes:
+      value: |
+        Thanks for taking time to fill out this bug report!
+        Please search among past open/closed issues for a similar one beforehand:
+          - https://github.com/gristlabs/grist-core/issues?q=
+          - https://community.getgrist.com/
+
+  - type: textarea
+    attributes:
+      label: Describe the current behavior
+
+  - type: textarea
+    attributes:
+      label: Steps to reproduce
+      value: |
+          1.
+          2.
+          3.
+
+  - type: textarea
+    attributes:
+      label: Describe the expected behavior
+
+  - type: checkboxes
+    attributes:
+      label: Where have you encountered this bug?
+      options:
+        - label: On [docs.getgrist.com](https://docs.getgrist.com)
+        - label: On a self-hosted instance 
+    validations:
+      required: true
+
+  - type: textarea
+    attributes:
+      label: Instance information (when self-hosting only)
+      description: In case you self-host, please share information above. You can discard any question you don't know the answer.
+      value: |
+          * Grist instance:
+            * Version:
+            * URL (if it's OK for you to share it):
+            * Installation mode: docker/kubernetes/...
+            * Architecture: single-worker/multi-workers
+
+          * Browser name, version and platforms on which you could reproduce the bug:
+          * Link to browser console log if relevant:
+          * Link to server log if relevant:
diff --git a/.github/ISSUE_TEMPLATE/10-installation-issue.yml b/.github/ISSUE_TEMPLATE/10-installation-issue.yml
new file mode 100644
index 00000000..1be89dab
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/10-installation-issue.yml
@@ -0,0 +1,33 @@
+# Inspired by PeerTube templates:
+# https://github.com/Chocobozzz/PeerTube/blob/master/.github/ISSUE_TEMPLATE/10-installation-issue.yml
+name: 🛠️ Installation/Upgrade Issue
+description: Use this template for installation/upgrade issues
+body:
+  - type: markdown
+    attributes:
+      value: |
+        Please check first the official documentation for self-hosting: https://support.getgrist.com/self-managed/
+
+  - type: markdown
+    attributes:
+      value: |
+        Please search among past open/closed issues for a similar one beforehand:
+          - https://github.com/gristlabs/grist-core/issues?q=
+          - https://community.getgrist.com/
+
+  - type: textarea
+    attributes:
+      label: Describe the problem
+
+  - type: textarea
+    attributes:
+      label: Additional information
+      value: |
+          * Grist version:
+          * Grist instance URL:
+          * SSO solution used and its version (if relevant):
+          * S3 storage solution and its version (if relevant):
+          * Docker version (if relevant):
+          * NodeJS version (if relevant):
+          * Redis version (if relevant):
+          * PostgreSQL version (if relevant):
diff --git a/.github/ISSUE_TEMPLATE/20-feature-request.yml b/.github/ISSUE_TEMPLATE/20-feature-request.yml
new file mode 100644
index 00000000..66f2cb38
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/20-feature-request.yml
@@ -0,0 +1,23 @@
+# Inspired by PeerTube templates:
+# https://github.com/Chocobozzz/PeerTube/blob/master/.github/ISSUE_TEMPLATE/30-feature-request.yml
+---
+name: ✨ Feature Request
+description: Use this template to ask for new features and suggest new ideas 💡
+body:
+  - type: markdown
+    attributes:
+      value: |
+        Thanks for taking time to share your ideas!
+        Please search among past open/closed issues for a similar one beforehand:
+          - https://github.com/gristlabs/grist-core/issues?q=
+          - https://community.getgrist.com/
+
+  - type: textarea
+    attributes:
+      label: Describe the problem to be solved
+      description: Provide a clear and concise description of what the problem is
+
+  - type: textarea
+    attributes:
+      label: Describe the solution you would like
+      description: Provide a clear and concise description of what you want to happen
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 00000000..7129ce0a
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,8 @@
+blank_issues_enabled: false
+contact_links:
+  - name: 🤷💻🤦 Question/Forum
+    url: https://community.getgrist.com/
+    about: You can ask and answer other questions here
+  - name: 💬 Discord
+    url: https://discord.com/invite/MYKpYQ3fbP
+    about: Chat with us via Discord for quick Q/A here and sharing tips
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
new file mode 100644
index 00000000..03b65db5
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -0,0 +1,27 @@
+## Context
+
+<!-- Please include a summary of the change, with motivation and context -->
+<!-- Bonus: if you are comfortable writing one, please insert a user-story https://en.wikipedia.org/wiki/User_story#Common_templates -->
+
+## Proposed solution
+
+<!-- Describe here how you address the issue -->
+
+## Related issues
+
+<!-- If suggesting a new feature or change, please discuss it in an issue first -->
+<!-- If fixing a bug, there should be an issue describing it with steps to reproduce -->
+<!-- If this does not solve entirely the issue, make also a checklist of what is done or not: -->
+
+## Has this been tested?
+
+<!-- Put an `x` in the box that applies: -->
+
+- [ ] 👍 yes, I added tests to the test suite
+- [ ] 💭 no, because this PR is a draft and still needs work
+- [ ] 🙅 no, because this is not relevant here
+- [ ] 🙋 no, because I need help <!-- Detail how we can help you -->
+
+## Screenshots / Screencasts
+
+<!-- delete if not relevant -->

From 7ae60e82ef6c35fb2a46ce51e07e74c1d1675cdb Mon Sep 17 00:00:00 2001
From: gallegonovato <fran-carro@hotmail.es>
Date: Fri, 2 Aug 2024 14:42:20 +0000
Subject: [PATCH 108/145] Translated using Weblate (Spanish)

Currently translated at 99.7% (1374 of 1378 strings)

Translation: Grist/client
Translate-URL: https://hosted.weblate.org/projects/grist/client/es/
---
 static/locales/es.client.json | 13 ++++++++++++-
 1 file changed, 12 insertions(+), 1 deletion(-)

diff --git a/static/locales/es.client.json b/static/locales/es.client.json
index aea535f6..23d1d66b 100644
--- a/static/locales/es.client.json
+++ b/static/locales/es.client.json
@@ -1612,7 +1612,9 @@
         "Session Secret": "Secreto de sesión",
         "Grist signs user session cookies with a secret key. Please set this key via the environment variable GRIST_SESSION_SECRET. Grist falls back to a hard-coded default when it is not set. We may remove this notice in the future since session IDs have been updated to be inherently cryptographically secure.": "Grist firma las cookies de sesión de usuario con una clave secreta. Establezca esta clave mediante la variable de entorno GRIST_SESSION_SECRET. Si no se establece, Grist vuelve a un valor predeterminado. Es posible que quitemos este aviso en el futuro, ya que los identificadores de sesión generados desde la versión 1.1.16 son intrínsecamente seguros desde el punto de vista criptográfico.",
         "Key to sign sessions with": "Clave para firmar sesiones con",
-        "Grist signs user session cookies with a secret key. Please set this key via the environment variable GRIST_SESSION_SECRET. Grist falls back to a hard-coded default when it is not set. We may remove this notice in the future as session IDs generated since v1.1.16 are inherently cryptographically secure.": "Grist firma las cookies de sesión de usuario con una clave secreta. Establezca esta clave mediante la variable de entorno GRIST_SESSION_SECRET. Si no se establece, Grist vuelve a un valor predeterminado. Es posible que quitemos este aviso en el futuro, ya que los identificadores de sesión generados desde la versión 1.1.16 son intrínsecamente seguros desde el punto de vista criptográfico."
+        "Grist signs user session cookies with a secret key. Please set this key via the environment variable GRIST_SESSION_SECRET. Grist falls back to a hard-coded default when it is not set. We may remove this notice in the future as session IDs generated since v1.1.16 are inherently cryptographically secure.": "Grist firma las cookies de sesión de usuario con una clave secreta. Establezca esta clave mediante la variable de entorno GRIST_SESSION_SECRET. Si no se establece, Grist vuelve a un valor predeterminado. Es posible que quitemos este aviso en el futuro, ya que los identificadores de sesión generados desde la versión 1.1.16 son intrínsecamente seguros desde el punto de vista criptográfico.",
+        "Enterprise": "Empresarial",
+        "Enable Grist Enterprise": "Activar Grist Empresarial"
     },
     "CreateTeamModal": {
         "Cancel": "Cancelar",
@@ -1719,5 +1721,14 @@
         "Go hands-on with the Grist Basics tutorial": "Práctica con el tutorial básico de Grist",
         "Go to the tutorial!": "¡Ve al tutorial!",
         "What organization are you with?": "¿A qué organización perteneces?"
+    },
+    "ViewLayout": {
+        "Delete": "Borrar",
+        "Delete data and this widget.": "Eliminar datos y este widget.",
+        "Keep data and delete widget. Table will remain available in {{rawDataLink}}": "Mantener datos y eliminar el widget. La tabla permanecerá disponible en {{rawDataLink}}",
+        "Table {{tableName}} will no longer be visible": "La tabla {{tableName}} ya no será visible"
+    },
+    "ToggleEnterpriseWidget": {
+        "An activation key is used to run Grist Enterprise after a trial period\nof 30 days has expired. Get an activation key by [signing up for Grist\nEnterprise]({{signupLink}}). You do not need an activation key to run\nGrist Core.\n\nLearn more in our [Help Center]({{helpCenter}}).": "Una clave de activación se utiliza para ejecutar Grist Empresarial después de un período de prueba\nde 30 días. Obtén una clave de activación [registrándose en Grist\nEmpresarial]({{signupLink}}). No necesitas una clave de activación para ejecutar\nGrist Core.\n\nMás información en nuestro [Centro de Ayuda]({{helpCenter}})."
     }
 }

From 09dcc81dda0c8eddb72cda855aee9c564005e6d9 Mon Sep 17 00:00:00 2001
From: gallegonovato <fran-carro@hotmail.es>
Date: Fri, 2 Aug 2024 20:31:47 +0000
Subject: [PATCH 109/145] Translated using Weblate (Spanish)

Currently translated at 100.0% (1378 of 1378 strings)

Translation: Grist/client
Translate-URL: https://hosted.weblate.org/projects/grist/client/es/
---
 static/locales/es.client.json | 10 +++++++---
 1 file changed, 7 insertions(+), 3 deletions(-)

diff --git a/static/locales/es.client.json b/static/locales/es.client.json
index 23d1d66b..5a4e304b 100644
--- a/static/locales/es.client.json
+++ b/static/locales/es.client.json
@@ -1614,7 +1614,7 @@
         "Key to sign sessions with": "Clave para firmar sesiones con",
         "Grist signs user session cookies with a secret key. Please set this key via the environment variable GRIST_SESSION_SECRET. Grist falls back to a hard-coded default when it is not set. We may remove this notice in the future as session IDs generated since v1.1.16 are inherently cryptographically secure.": "Grist firma las cookies de sesión de usuario con una clave secreta. Establezca esta clave mediante la variable de entorno GRIST_SESSION_SECRET. Si no se establece, Grist vuelve a un valor predeterminado. Es posible que quitemos este aviso en el futuro, ya que los identificadores de sesión generados desde la versión 1.1.16 son intrínsecamente seguros desde el punto de vista criptográfico.",
         "Enterprise": "Empresarial",
-        "Enable Grist Enterprise": "Activar Grist Empresarial"
+        "Enable Grist Enterprise": "Activar Grist Enterprise"
     },
     "CreateTeamModal": {
         "Cancel": "Cancelar",
@@ -1726,9 +1726,13 @@
         "Delete": "Borrar",
         "Delete data and this widget.": "Eliminar datos y este widget.",
         "Keep data and delete widget. Table will remain available in {{rawDataLink}}": "Mantener datos y eliminar el widget. La tabla permanecerá disponible en {{rawDataLink}}",
-        "Table {{tableName}} will no longer be visible": "La tabla {{tableName}} ya no será visible"
+        "Table {{tableName}} will no longer be visible": "La tabla {{tableName}} ya no será visible",
+        "raw data page": "página de datos en bruto"
     },
     "ToggleEnterpriseWidget": {
-        "An activation key is used to run Grist Enterprise after a trial period\nof 30 days has expired. Get an activation key by [signing up for Grist\nEnterprise]({{signupLink}}). You do not need an activation key to run\nGrist Core.\n\nLearn more in our [Help Center]({{helpCenter}}).": "Una clave de activación se utiliza para ejecutar Grist Empresarial después de un período de prueba\nde 30 días. Obtén una clave de activación [registrándose en Grist\nEmpresarial]({{signupLink}}). No necesitas una clave de activación para ejecutar\nGrist Core.\n\nMás información en nuestro [Centro de Ayuda]({{helpCenter}})."
+        "An activation key is used to run Grist Enterprise after a trial period\nof 30 days has expired. Get an activation key by [signing up for Grist\nEnterprise]({{signupLink}}). You do not need an activation key to run\nGrist Core.\n\nLearn more in our [Help Center]({{helpCenter}}).": "Una clave de activación se utiliza para ejecutar Grist Enterprise después de un período de prueba\nde 30 días. Obtén una clave de activación [registrándose en Grist\nEmpresarial]({{signupLink}}). No necesitas una clave de activación para ejecutar\nGrist Core.\n\nMás información en nuestro [Centro de Ayuda]({{helpCenter}}).",
+        "Disable Grist Enterprise": "Desactivar Grist Enterprise",
+        "Enable Grist Enterprise": "Activar Grist Enterprise",
+        "Grist Enterprise is **enabled**.": "Grist Enterprise está **activado**."
     }
 }

From d6bdb0e72645abcb6d8d574914c86a45f8d15287 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Fran=C4=8Dek=20Prijatelj?= <prijatelj.francek@gmail.com>
Date: Sat, 3 Aug 2024 15:36:29 +0000
Subject: [PATCH 110/145] Translated using Weblate (Slovenian)

Currently translated at 100.0% (1378 of 1378 strings)

Translation: Grist/client
Translate-URL: https://hosted.weblate.org/projects/grist/client/sl/
---
 static/locales/sl.client.json | 49 ++++++++++++++++++++++++++++++++++-
 1 file changed, 48 insertions(+), 1 deletion(-)

diff --git a/static/locales/sl.client.json b/static/locales/sl.client.json
index e7314b14..5c4b9cf3 100644
--- a/static/locales/sl.client.json
+++ b/static/locales/sl.client.json
@@ -1558,7 +1558,9 @@
         "Key to sign sessions with": "Ključ za podpisovanje sej",
         "Session Secret": "Skrivnost seje",
         "Grist signs user session cookies with a secret key. Please set this key via the environment variable GRIST_SESSION_SECRET. Grist falls back to a hard-coded default when it is not set. We may remove this notice in the future since session IDs have been updated to be inherently cryptographically secure.": "Grist podpisuje piškotke uporabniške seje s skrivnim ključem. Ta ključ nastavite prek spremenljivke okolja GRIST_SESSION_SECRET. Grist se vrne na trdo kodirano privzeto vrednost, če ni nastavljena. To obvestilo bomo morda odstranili v prihodnosti, saj so ID-ji sej, ustvarjeni od različice 1.1.16, sami po sebi kriptografsko varni.",
-        "Grist signs user session cookies with a secret key. Please set this key via the environment variable GRIST_SESSION_SECRET. Grist falls back to a hard-coded default when it is not set. We may remove this notice in the future as session IDs generated since v1.1.16 are inherently cryptographically secure.": "Grist podpisuje piškotke uporabniške seje s skrivnim ključem. Ta ključ nastavite prek spremenljivke okolja GRIST_SESSION_SECRET. Grist se vrne na trdo kodirano privzeto vrednost, če ni nastavljena. To obvestilo bomo morda odstranili v prihodnosti, saj so ID-ji sej, ustvarjeni od različice 1.1.16, sami po sebi kriptografsko varni."
+        "Grist signs user session cookies with a secret key. Please set this key via the environment variable GRIST_SESSION_SECRET. Grist falls back to a hard-coded default when it is not set. We may remove this notice in the future as session IDs generated since v1.1.16 are inherently cryptographically secure.": "Grist podpisuje piškotke uporabniške seje s skrivnim ključem. Ta ključ nastavite prek spremenljivke okolja GRIST_SESSION_SECRET. Grist se vrne na trdo kodirano privzeto vrednost, če ni nastavljena. To obvestilo bomo morda odstranili v prihodnosti, saj so ID-ji sej, ustvarjeni od različice 1.1.16, sami po sebi kriptografsko varni.",
+        "Enable Grist Enterprise": "Omogoči Grist Enterprise",
+        "Enterprise": "Podjetje"
     },
     "ChoiceEditor": {
         "Error in dropdown condition": "Napaka v spustnem meniju",
@@ -1633,5 +1635,50 @@
         "Average Time (s)": "Povprečni čas (i)",
         "Column ID": "ID stolpca",
         "Formula timer": "Časovnik formule"
+    },
+    "DocTutorial": {
+        "Click to expand": "Kliknite za razširitev",
+        "Do you want to restart the tutorial? All progress will be lost.": "Ali želite znova zagnati učbenik? Ves napredek bo izgubljen.",
+        "End tutorial": "Končaj vadnico",
+        "Finish": "Končaj",
+        "Next": "Naslednji",
+        "Previous": "Prejšnji",
+        "Restart": "Ponovni zagon"
+    },
+    "OnboardingCards": {
+        "3 minute video tour": "3 minutni video ogled",
+        "Complete our basics tutorial": "Dokončaj našo vadnico o osnovah",
+        "Complete the tutorial": "Dokončaj vadnico",
+        "Learn the basic of reference columns, linked widgets, column types, & cards.": "Nauči se osnov referenčnih stolpcev, povezanih pripomočkov, vrst stolpcev in kartic."
+    },
+    "OnboardingPage": {
+        "Back": "Nazaj",
+        "Next step": "Naslednji korak",
+        "Skip step": "Preskoči korak",
+        "Skip tutorial": "Preskoči vadnico",
+        "Tell us who you are": "Povej nam, kdo si",
+        "Type here": "Piši tukaj",
+        "Welcome": "Dobrodošel",
+        "What brings you to Grist (you can select multiple)?": "Kaj te je pripeljalo do Grista (lahko izbereš več)?",
+        "What is your role?": "Kakšna je tvoja vloga?",
+        "What organization are you with?": "V kateri organizaciji si?",
+        "Your organization": "Tvoja organizacija",
+        "Your role": "Tvoja vloga",
+        "Go hands-on with the Grist Basics tutorial": "Preizkusi vadnico Osnove Grista",
+        "Discover Grist in 3 minutes": "Odkrij Grist v 3 minutah",
+        "Go to the tutorial!": "Pojdi na vadnico!"
+    },
+    "ToggleEnterpriseWidget": {
+        "Disable Grist Enterprise": "Onemogoči Grist Enterprise",
+        "Enable Grist Enterprise": "Omogoči Grist Enterprise",
+        "Grist Enterprise is **enabled**.": "Grist Enterprise je **omogočen**.",
+        "An activation key is used to run Grist Enterprise after a trial period\nof 30 days has expired. Get an activation key by [signing up for Grist\nEnterprise]({{signupLink}}). You do not need an activation key to run\nGrist Core.\n\nLearn more in our [Help Center]({{helpCenter}}).": "Za zagon Grist Enterprise po poskusnem obdobju 30 dni potrebujete aktivacijski ključ. Pridobite aktivacijski ključ tako, da se [prijavite za Grist\nEnterprise]({{signupLink}}). Za delovanje Grist Core ne potrebujete aktivacijskega ključa\n.\n\nIzvedite več v našem [centru za pomoč]({{helpCenter}})."
+    },
+    "ViewLayout": {
+        "Delete": "Briši",
+        "Delete data and this widget.": "Izbriši podatke in ta pripomoček.",
+        "Keep data and delete widget. Table will remain available in {{rawDataLink}}": "Ohranite podatke in izbrišite pripomoček. Tabela bo ostala na voljo v {{rawDataLink}}",
+        "Table {{tableName}} will no longer be visible": "Tabela {{tableName}} ne bo več vidna",
+        "raw data page": "stran z neobdelanimi podatki"
     }
 }

From a7a11d378e91c258f2c077d23615f6d06dce0e63 Mon Sep 17 00:00:00 2001
From: Paul Janzen <pj@paulgjanzen.com>
Date: Mon, 5 Aug 2024 00:02:18 +0000
Subject: [PATCH 111/145] Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (1378 of 1378 strings)

Translation: Grist/client
Translate-URL: https://hosted.weblate.org/projects/grist/client/pt_BR/
---
 static/locales/pt_BR.client.json | 52 ++++++++++++++++++++++++++++++--
 1 file changed, 50 insertions(+), 2 deletions(-)

diff --git a/static/locales/pt_BR.client.json b/static/locales/pt_BR.client.json
index c41e9b0e..f8c9f9ca 100644
--- a/static/locales/pt_BR.client.json
+++ b/static/locales/pt_BR.client.json
@@ -1297,7 +1297,8 @@
         "Removed webhook.": "Webhook removido.",
         "Webhook Id": "Id do webhook",
         "Table": "Tabela",
-        "Filter for changes in these columns (semicolon-separated ids)": "Filtrar as alterações nessas Colunas (ids separados por ponto e vírgula)"
+        "Filter for changes in these columns (semicolon-separated ids)": "Filtrar as alterações nessas Colunas (ids separados por ponto e vírgula)",
+        "Header Authorization": "Autorização de cabeçalho"
     },
     "FieldContextMenu": {
         "Copy anchor link": "Copiar link de âncora",
@@ -1621,7 +1622,9 @@
         "Key to sign sessions with": "Chave para assinar sessões com",
         "Session Secret": "Segredo da sessão",
         "Grist signs user session cookies with a secret key. Please set this key via the environment variable GRIST_SESSION_SECRET. Grist falls back to a hard-coded default when it is not set. We may remove this notice in the future since session IDs have been updated to be inherently cryptographically secure.": "O Grist assina os cookies de sessão do usuário com uma chave secreta. Defina essa chave por meio da variável de ambiente GRIST_SESSION_SECRET. O Grist retorna a um padrão codificado quando ele não está definido. Poderemos remover esse aviso no futuro, pois os IDs de sessão gerados desde a versão 1.1.16 são inerentemente seguros em termos de criptografia.",
-        "Grist signs user session cookies with a secret key. Please set this key via the environment variable GRIST_SESSION_SECRET. Grist falls back to a hard-coded default when it is not set. We may remove this notice in the future as session IDs generated since v1.1.16 are inherently cryptographically secure.": "O Grist assina os cookies de sessão do usuário com uma chave secreta. Defina essa chave por meio da variável de ambiente GRIST_SESSION_SECRET. O Grist retorna a um padrão codificado quando ele não está definido. Poderemos remover esse aviso no futuro, pois os IDs de sessão gerados desde a versão 1.1.16 são inerentemente seguros em termos de criptografia."
+        "Grist signs user session cookies with a secret key. Please set this key via the environment variable GRIST_SESSION_SECRET. Grist falls back to a hard-coded default when it is not set. We may remove this notice in the future as session IDs generated since v1.1.16 are inherently cryptographically secure.": "O Grist assina os cookies de sessão do usuário com uma chave secreta. Defina essa chave por meio da variável de ambiente GRIST_SESSION_SECRET. O Grist retorna a um padrão codificado quando ele não está definido. Poderemos remover esse aviso no futuro, pois os IDs de sessão gerados desde a versão 1.1.16 são inerentemente seguros em termos de criptografia.",
+        "Enterprise": "Empresarial",
+        "Enable Grist Enterprise": "Habilitar a Grist Empresarial"
     },
     "Field": {
         "No choices configured": "Nenhuma opção configurada",
@@ -1696,5 +1699,50 @@
         "Loading timing data. Don't close this tab.": "Carregando dados de tempo. Não feche essa guia.",
         "Number of Calls": "Número de chamadas",
         "Table ID": "ID da tabela"
+    },
+    "DocTutorial": {
+        "Do you want to restart the tutorial? All progress will be lost.": "Você quer reiniciar o tutorial? Todo o progresso será perdido.",
+        "Finish": "Terminar",
+        "Restart": "Reiniciar",
+        "Click to expand": "Clique para expandir",
+        "Previous": "Anterior",
+        "End tutorial": "Finalizar tutorial",
+        "Next": "Próximo"
+    },
+    "OnboardingCards": {
+        "3 minute video tour": "Vídeo tour de 3 minutos",
+        "Complete our basics tutorial": "Conclua nosso tutorial básico",
+        "Complete the tutorial": "Concluir o tutorial",
+        "Learn the basic of reference columns, linked widgets, column types, & cards.": "Aprenda o básico sobre Colunas de referência, widgets vinculados, tipos de colunas e cartões."
+    },
+    "OnboardingPage": {
+        "Discover Grist in 3 minutes": "Descubra Grist em 3 minutos",
+        "Go hands-on with the Grist Basics tutorial": "Pratique com o tutorial Conceitos Básicos do Grist",
+        "Go to the tutorial!": "Vá para o tutorial!",
+        "Next step": "Próximo passo",
+        "Skip tutorial": "Pular o tutorial",
+        "Tell us who you are": "Diga-nos quem você é",
+        "Type here": "Digite aqui",
+        "What brings you to Grist (you can select multiple)?": "O que o traz ao Grist (você pode selecionar várias opções)?",
+        "What is your role?": "Qual é a sua função?",
+        "What organization are you with?": "Em que organização você está?",
+        "Your organization": "Sua organização",
+        "Your role": "Sua função",
+        "Skip step": "Pular passo",
+        "Back": "Voltar",
+        "Welcome": "Bem-vindo"
+    },
+    "ToggleEnterpriseWidget": {
+        "Disable Grist Enterprise": "Desativar o Grist Empresarial",
+        "Enable Grist Enterprise": "Habilitar a Grist Empresarial",
+        "Grist Enterprise is **enabled**.": "O Grist Empresarial está **habilitado**.",
+        "An activation key is used to run Grist Enterprise after a trial period\nof 30 days has expired. Get an activation key by [signing up for Grist\nEnterprise]({{signupLink}}). You do not need an activation key to run\nGrist Core.\n\nLearn more in our [Help Center]({{helpCenter}}).": "Uma chave de ativação é usada para executar o Grist Enterprise após um período de avaliação\nde 30 dias tenha expirado. Obtenha uma chave de ativação [inscrevendo-se no Grist\nEmpresarial]({{signupLink}}). Você não precisa de uma chave de ativação para executar o\nGrist Core.\n\nSaiba mais em nossa [Central de Ajuda]({{helpCenter}})."
+    },
+    "ViewLayout": {
+        "Delete": "Excluir",
+        "Delete data and this widget.": "Excluir dados e este widget.",
+        "Keep data and delete widget. Table will remain available in {{rawDataLink}}": "Mantenha os dados e exclua o widget. A tabela permanecerá disponível em {{rawDataLink}}",
+        "Table {{tableName}} will no longer be visible": "A tabela {{tableName}} não estará mais visível",
+        "raw data page": "página de dados brutos"
     }
 }

From 5dfc2f6009c9865914f1cd27c32a7bc4b7778a6d Mon Sep 17 00:00:00 2001
From: Paul Janzen <pj@paulgjanzen.com>
Date: Mon, 5 Aug 2024 00:11:40 +0000
Subject: [PATCH 112/145] Translated using Weblate (German)

Currently translated at 100.0% (1378 of 1378 strings)

Translation: Grist/client
Translate-URL: https://hosted.weblate.org/projects/grist/client/de/
---
 static/locales/de.client.json | 52 +++++++++++++++++++++++++++++++++--
 1 file changed, 50 insertions(+), 2 deletions(-)

diff --git a/static/locales/de.client.json b/static/locales/de.client.json
index c16e2911..dcc10a92 100644
--- a/static/locales/de.client.json
+++ b/static/locales/de.client.json
@@ -1305,7 +1305,8 @@
         "Removed webhook.": "Der Webhook wurde entfernt.",
         "Webhook Id": "Webhook Id",
         "Filter for changes in these columns (semicolon-separated ids)": "Filter für Änderungen in diesen Spalten (durch Semikolon getrennte IDs)",
-        "Cleared webhook queue.": "Webhook-Warteschlange geleert."
+        "Cleared webhook queue.": "Webhook-Warteschlange geleert.",
+        "Header Authorization": "Kopfzeilen-Autorisierung"
     },
     "FormulaAssistant": {
         "Ask the bot.": "Fragen Sie den Bot.",
@@ -1617,7 +1618,9 @@
         "Grist signs user session cookies with a secret key. Please set this key via the environment variable GRIST_SESSION_SECRET. Grist falls back to a hard-coded default when it is not set. We may remove this notice in the future since session IDs have been updated to be inherently cryptographically secure.": "Grist signiert die Sitzungscookies der Benutzer mit einem geheimen Schlüssel. Bitte setzen Sie diesen Schlüssel über die Umgebungsvariable GRIST_SESSION_SECRET. Grist greift auf eine hart kodierte Voreinstellung zurück, wenn sie nicht gesetzt ist. Wir werden diesen Hinweis möglicherweise in Zukunft entfernen, da Sitzungs-IDs, die seit v1.1.16 erzeugt werden, von Natur aus kryptographisch sicher sind.",
         "Grist signs user session cookies with a secret key. Please set this key via the environment variable GRIST_SESSION_SECRET. Grist falls back to a hard-coded default when it is not set. We may remove this notice in the future as session IDs generated since v1.1.16 are inherently cryptographically secure.": "Grist signiert die Sitzungscookies der Benutzer mit einem geheimen Schlüssel. Bitte setzen Sie diesen Schlüssel über die Umgebungsvariable GRIST_SESSION_SECRET. Grist greift auf eine hart kodierte Voreinstellung zurück, wenn sie nicht gesetzt ist. Wir werden diesen Hinweis möglicherweise in Zukunft entfernen, da Sitzungs-IDs, die seit v1.1.16 erzeugt werden, von Natur aus kryptographisch sicher sind.",
         "Key to sign sessions with": "Schlüssel zum Anmelden von Sitzungen mit",
-        "Session Secret": "Sitzungsgeheimnis"
+        "Session Secret": "Sitzungsgeheimnis",
+        "Enterprise": "Unternehmen",
+        "Enable Grist Enterprise": "Aktivieren Sie Grist Enterprise"
     },
     "Section": {
         "Insert section above": "Abschnitt oben einfügen",
@@ -1696,5 +1699,50 @@
         "Average Time (s)": "Durchschnittliche Zeit(en)",
         "Loading timing data. Don't close this tab.": "Zeitpunktsdaten laden. Schließen Sie diese Registerkarte nicht.",
         "Column ID": "Spalte ID"
+    },
+    "DocTutorial": {
+        "Click to expand": "Klicken Sie zum Erweitern",
+        "Finish": "Fertig",
+        "Do you want to restart the tutorial? All progress will be lost.": "Möchten Sie das Tutorial neu starten? Alle Fortschritte gehen dann verloren.",
+        "End tutorial": "Tutorial beenden",
+        "Next": "Nächste",
+        "Restart": "Neustart",
+        "Previous": "Vorherige"
+    },
+    "OnboardingPage": {
+        "Back": "Zurück",
+        "Type here": "Hier tippen",
+        "Welcome": "Willkommen",
+        "What brings you to Grist (you can select multiple)?": "Was bringt Sie zu Grist (Sie können mehrere auswählen)?",
+        "Discover Grist in 3 minutes": "Entdecken Sie Grist in 3 Minuten",
+        "Go hands-on with the Grist Basics tutorial": "Praktische Übungen mit dem Grist Basics-Tutorial",
+        "Go to the tutorial!": "Gehen Sie zum Tutorial!",
+        "Next step": "Nächster Schritt",
+        "Tell us who you are": "Sagen Sie uns, wer Sie sind",
+        "What is your role?": "Was ist Ihre Aufgabe?",
+        "What organization are you with?": "In welches Unternehem sind Sie tätig?",
+        "Your organization": "Ihr Unternehmen",
+        "Your role": "Ihre Rolle",
+        "Skip step": "Schritt überspringen",
+        "Skip tutorial": "Tutorial überspringen"
+    },
+    "OnboardingCards": {
+        "Complete the tutorial": "Fertigen Sie das Tutorial",
+        "3 minute video tour": "3 Minuten Video-Tour",
+        "Learn the basic of reference columns, linked widgets, column types, & cards.": "Lernen Sie die Grundlagen von Referenzspalten, verknüpften Widgets, Spaltentypen und Karten kennen.",
+        "Complete our basics tutorial": "Vervollständigen Sie unser Grundlagen-Tutorial"
+    },
+    "ToggleEnterpriseWidget": {
+        "Disable Grist Enterprise": "Grist Enterprise deaktivieren",
+        "Enable Grist Enterprise": "Grist Enterprise aktivieren",
+        "An activation key is used to run Grist Enterprise after a trial period\nof 30 days has expired. Get an activation key by [signing up for Grist\nEnterprise]({{signupLink}}). You do not need an activation key to run\nGrist Core.\n\nLearn more in our [Help Center]({{helpCenter}}).": "Ein Aktivierungsschlüssel wird verwendet, um Grist Enterprise nach Ablauf einer Testphase\nvon 30 Tagen. Sie erhalten einen Aktivierungsschlüssel, indem Sie [sich für Grist\nEnterprise anmelden]({{signupLink}}). Sie brauchen keinen Aktivierungsschlüssel, um\nGrist Core zu benutzen.\n\nErfahren Sie mehr in unserem [Help Center]({{helpCenter}}).",
+        "Grist Enterprise is **enabled**.": "Grist Enterprise ist **aktiviert**."
+    },
+    "ViewLayout": {
+        "Delete": "Löschen",
+        "Table {{tableName}} will no longer be visible": "Die Tabelle {{tableName}} wird nicht mehr sichtbar sein.",
+        "raw data page": "Rohdaten-Seite",
+        "Delete data and this widget.": "Daten und dieses Widget löschen.",
+        "Keep data and delete widget. Table will remain available in {{rawDataLink}}": "Daten behalten und Widget löschen. Die Tabelle bleibt verfügbar in {{rawDataLink}}"
     }
 }

From 9a761caf9f51260ad8a2a2e022634c0515c8375e Mon Sep 17 00:00:00 2001
From: xabirequejo <xabi.rn@gmail.com>
Date: Sun, 4 Aug 2024 11:07:58 +0000
Subject: [PATCH 113/145] Translated using Weblate (Basque)

Currently translated at 99.8% (1376 of 1378 strings)

Translation: Grist/client
Translate-URL: https://hosted.weblate.org/projects/grist/client/eu/
---
 static/locales/eu.client.json | 27 +++++++++++++++++++++------
 1 file changed, 21 insertions(+), 6 deletions(-)

diff --git a/static/locales/eu.client.json b/static/locales/eu.client.json
index 49bc7130..cb51405f 100644
--- a/static/locales/eu.client.json
+++ b/static/locales/eu.client.json
@@ -481,7 +481,7 @@
         "Sign in": "Hasi saioa",
         "To use Grist, please either sign up or sign in.": "Grist erabiltzeko eman izena edo hasi saioa.",
         "Visit our {{link}} to learn more about Grist.": "Bisitatu {{link}} Grist-i buruz gehiago ikasteko.",
-        "Help Center": "Laguntza gunea",
+        "Help Center": "Laguntza Gunea",
         "Import Document": "Inportatu dokumentua",
         "Welcome to {{orgName}}": "Ongi etorri {{orgName}}(e)ra",
         "Sign up": "Eman izena",
@@ -513,7 +513,7 @@
         "Tutorial": "Tutoriala"
     },
     "LeftPanelCommon": {
-        "Help Center": "Laguntza gunea"
+        "Help Center": "Laguntza Gunea"
     },
     "MakeCopyMenu": {
         "As Template": "Txantiloi gisa",
@@ -1002,7 +1002,7 @@
         "Configuring your document": "Dokumentua konfiguratzen",
         "Editing Data": "Datuak editatzen",
         "Enter": "Sartu",
-        "Help Center": "Laguntza gunea",
+        "Help Center": "Laguntza Gunea",
         "Use the Share button ({{share}}) to share the document or export data.": "Erabili Partekatu botoia ({{share}}) dokumentua partekatu edo datuak esportatzeko.",
         "Welcome to Grist!": "Ongi etorri Grist-era!",
         "template library": "txantiloi liburutegia",
@@ -1327,7 +1327,7 @@
         "User has view access to {{resource}} resulting from manually-set access to resources inside. If removed here, this user will lose access to resources inside.": "Erabiltzaileak {{resource}}-rako bistarako sarbidea du, barneko baliabideetarako sarbidea eskuz ezartzearen ondorioz. Hemendik kenduz gero, barruan dauden baliabideetarako sarbidea galduko du."
     },
     "SupportGristNudge": {
-        "Help Center": "Laguntza-gunea",
+        "Help Center": "Laguntza Gunea",
         "Opt in to Telemetry": "Bidali telemetria",
         "Support Grist": "Eman babesa Grist-i",
         "Support Grist page": "Eman babesa Grist-en orriari",
@@ -1339,7 +1339,7 @@
     },
     "SupportGristPage": {
         "GitHub Sponsors page": "GitHub Sponsors orria",
-        "Help Center": "Laguntza-gunea",
+        "Help Center": "Laguntza Gunea",
         "Home": "Hasiera",
         "Support Grist": "Eman babesa Grist-i",
         "Telemetry": "Telemetria",
@@ -1535,7 +1535,9 @@
         "You do not have access to the administrator panel.\nPlease log in as an administrator.": "Ez duzu administratzailearen mahaigainera sarbiderik.\nHasi saioa administratzaile gisa.",
         "Key to sign sessions with": "Saioak sinatzeko gakoa",
         "Grist allows for very powerful formulas, using Python. We recommend setting the environment variable GRIST_SANDBOX_FLAVOR to gvisor if your hardware supports it (most will), to run formulas in each document within a sandbox isolated from other documents and isolated from the network.": "Gristek formula oso boteretsuak onartzen ditu, Python erabiliz. Dokumentu bakoitzean beste dokumentu batzuetatik eta saretik isolatutako sandbox baten barruan formulak exekutatzeko, GRIST_SANDBOX_FLAVOR aldagaia gvisor-era aldatzea gomendatzen dugu, zure hardwarea bateragarria bada (gehienak badira).",
-        "Session Secret": "Saioaren gakoa"
+        "Session Secret": "Saioaren gakoa",
+        "Enable Grist Enterprise": "Gaitu Grist Enterprise",
+        "Enterprise": "Enterprise"
     },
     "Columns": {
         "Remove Column": "Kendu zutabea"
@@ -1665,5 +1667,18 @@
         "What organization are you with?": "Zein erakunderekin jarduten duzu?",
         "Your role": "Zure rola",
         "Your organization": "Zure erakundea"
+    },
+    "ToggleEnterpriseWidget": {
+        "Grist Enterprise is **enabled**.": "Grist Enterprise **gaituta** dago.",
+        "Disable Grist Enterprise": "Ezgaitu Grist Enterprise",
+        "Enable Grist Enterprise": "Gaitu Grist Enterprise",
+        "An activation key is used to run Grist Enterprise after a trial period\nof 30 days has expired. Get an activation key by [signing up for Grist\nEnterprise]({{signupLink}}). You do not need an activation key to run\nGrist Core.\n\nLearn more in our [Help Center]({{helpCenter}}).": "Aktibazio-gako bat erabili da 30 eguneko epea iraungi eta probaldiaren\nondoren Grist Enterprise exekutatzeko. Eskuratu aktibazio-gako bat [Grist\nEnterprise-n izena eman]({{signupLink}})ez. Ez duzu aktibazio-gakorik behar\nGrist Core exekutatzeko.\n\nLortu informazio gehiago gure [Laguntza Gunea]({{helpCenter}})n."
+    },
+    "ViewLayout": {
+        "Delete data and this widget.": "Ezabatu datuak eta widgeta.",
+        "Keep data and delete widget. Table will remain available in {{rawDataLink}}": "Mantendu datuak eta ezabatu widgeta. Taulak erabilgarri egoten jarraituko du {{rawDataLink}}(e)n",
+        "Table {{tableName}} will no longer be visible": "{{tableName}} taula ez da ikusgai egongo aurrerantzean",
+        "raw data page": "datu gordinen orria",
+        "Delete": "Ezabatu"
     }
 }

From 4bfcbf20ac5aa26b787ee670776786966747a3b8 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= <jordigh@octave.org>
Date: Mon, 5 Aug 2024 11:08:17 -0400
Subject: [PATCH 114/145] markdown: document this function

---
 app/client/lib/markdown.ts | 18 ++++++++++++++++++
 1 file changed, 18 insertions(+)

diff --git a/app/client/lib/markdown.ts b/app/client/lib/markdown.ts
index 49365b08..ce9d1ab5 100644
--- a/app/client/lib/markdown.ts
+++ b/app/client/lib/markdown.ts
@@ -2,6 +2,24 @@ import { sanitizeHTML } from 'app/client/ui/sanitizeHTML';
 import { BindableValue, DomElementMethod, subscribeElem } from 'grainjs';
 import { marked } from 'marked';
 
+/**
+ * Helper function for using Markdown in grainjs elements. It accepts
+ * both plain Markdown strings, as well as methods that use an observable.
+ * Example usage:
+ *
+ *    cssSection(markdown(t(`# New Markdown Function
+ *
+ *      We can _write_ [the usual Markdown](https://markdownguide.org) *inside*
+ *      a Grainjs element.`)));
+ *
+ * or
+ *
+ *   cssSection(markdown(use => use(toggle) ? t('The toggle is **on**') : t('The toggle is **off**'));
+ *
+ * Markdown strings are easier for our translators to handle, as it's possible
+ * to include all of the context around a single markdown string without
+ * breaking it up into separate strings for grainjs elements.
+ */
 export function markdown(markdownObs: BindableValue<string>): DomElementMethod {
   return elem => subscribeElem(elem, markdownObs, value => setMarkdownValue(elem, value));
 }

From 95189158c05e9a986043a4e18b94e3a6bf805d9d Mon Sep 17 00:00:00 2001
From: jarek <jaroslaw.sadzinski@gmail.com>
Date: Mon, 5 Aug 2024 23:27:34 +0200
Subject: [PATCH 115/145] Enabling BiDi mode (#1152)

---
 test/nbrowser/testUtils.ts | 10 ++++++++++
 1 file changed, 10 insertions(+)

diff --git a/test/nbrowser/testUtils.ts b/test/nbrowser/testUtils.ts
index 562bf855..14cc494c 100644
--- a/test/nbrowser/testUtils.ts
+++ b/test/nbrowser/testUtils.ts
@@ -33,6 +33,16 @@ setOptionsModifyFunc(({chromeOpts, firefoxOpts}) => {
   // Set "kiosk" printing that saves to PDF without offering any dialogs. This applies to regular
   // (non-headless) Chrome. On headless Chrome, no dialog or output occurs regardless.
   chromeOpts.addArguments("--kiosk-printing");
+  // Latest chrome version 127, has started ignoring alerts and popups when controlled via a
+  // webdriver.
+  // https://github.com/SeleniumHQ/selenium/issues/14290
+  // According to the article above, popups and alerts are still shown in `BiDi` sessions. While we
+  // don't have latest webdriver library (where the new `enableBiDi` method is exposed), it can be
+  // toggled by using the `set` method in `capabilities` interface, as it is done here (long URL):
+
+  // eslint-disable-next-line max-len
+  // https://github.com/shs96c/selenium/blob/ff82c4af6a493321d9eaec6ba8fa8589e4aa824d/javascript/node/selenium-webdriver/firefox.js#L415
+  chromeOpts.set('webSocketUrl', true);
 
   chromeOpts.setUserPreferences({
     // Don't show popups to save passwords, which are shown when running against a deployment when

From 1ce26ea6f59c44e7d2031cef5e1a2cf08ca2fb33 Mon Sep 17 00:00:00 2001
From: George Gevoian <george@gevoian.com>
Date: Sun, 4 Aug 2024 23:27:05 -0400
Subject: [PATCH 116/145] (core) Fix typo in tutorial card

Test Plan: N/A

Reviewers: jarek

Reviewed By: jarek

Differential Revision: https://phab.getgrist.com/D4308
---
 app/client/ui/OnboardingCards.ts | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/app/client/ui/OnboardingCards.ts b/app/client/ui/OnboardingCards.ts
index 9781eaf7..4e357169 100644
--- a/app/client/ui/OnboardingCards.ts
+++ b/app/client/ui/OnboardingCards.ts
@@ -50,7 +50,7 @@ export function buildOnboardingCards(
           t('Complete our basics tutorial'),
         ),
         cssTutorialCardSubHeader(
-          t('Learn the basic of reference columns, linked widgets, column types, & cards.')
+          t('Learn the basics of reference columns, linked widgets, column types, & cards.')
         ),
         cssTutorialCardBody(
           cssTutorialProgress(

From 952544432e3deb84b9e3996d93c9ef72d88c6e98 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= <jordigh@octave.org>
Date: Mon, 5 Aug 2024 14:51:47 -0400
Subject: [PATCH 117/145] UserManager: show proper org domain (#476)

We had `getgrist.com` hardcoded here, which only works for SaaS. The
base domain as well as the way that orgs are encoded in the URL can be
different in other circumstances.

If we are encoding orgs in the domain name, that's easy. We just do
`orgname.base.domain.name`. If we are not, then we first try a base
domain, and if that isn't set, we'll use the domain of the home
server.
---
 app/client/ui/UserManager.ts | 31 +++++++++++++++++++++----------
 1 file changed, 21 insertions(+), 10 deletions(-)

diff --git a/app/client/ui/UserManager.ts b/app/client/ui/UserManager.ts
index b615608b..42fb0909 100644
--- a/app/client/ui/UserManager.ts
+++ b/app/client/ui/UserManager.ts
@@ -6,8 +6,9 @@
  * It can be instantiated by calling showUserManagerModal with the UserAPI and IUserManagerOptions.
  */
 import { makeT } from 'app/client/lib/localization';
-import {commonUrls} from 'app/common/gristUrls';
+import {commonUrls, isOrgInPathOnly} from 'app/common/gristUrls';
 import {capitalizeFirstWord, isLongerThan} from 'app/common/gutil';
+import {getGristConfig} from 'app/common/urlUtils';
 import {FullUser} from 'app/common/LoginSessionAPI';
 import * as roles from 'app/common/roles';
 import {Organization, PermissionData, UserAPI} from 'app/common/UserAPI';
@@ -816,15 +817,25 @@ const cssMemberPublicAccess = styled(cssMemberSecondary, `
 function renderTitle(resourceType: ResourceType, resource?: Resource, personal?: boolean) {
   switch (resourceType) {
     case 'organization': {
-      if (personal) { return t('Your role for this team site'); }
-      return [
-        t('Manage members of team site'),
-        !resource ? null : cssOrgName(
-          `${(resource as Organization).name} (`,
-          cssOrgDomain(`${(resource as Organization).domain}.getgrist.com`),
-          ')',
-        )
-      ];
+      if (personal) {
+        return t('Your role for this team site');
+      }
+
+      function getOrgDisplay() {
+        if (!resource) {
+          return null;
+        }
+
+        const org = resource as Organization;
+        const gristConfig = getGristConfig();
+        const gristHomeHost = gristConfig.homeUrl ? new URL(gristConfig.homeUrl).host : '';
+        const baseDomain = gristConfig.baseDomain || gristHomeHost;
+        const orgDisplay = isOrgInPathOnly() ? `${baseDomain}/o/${org.domain}` : `${org.domain}${baseDomain}`;
+
+        return cssOrgName(`${org.name} (`, cssOrgDomain(orgDisplay), ')');
+      }
+
+      return [t('Manage members of team site'), getOrgDisplay()];
     }
     default: {
       return personal ?

From ba7b72b39a3322007bea4bf3bbd0bfc4ce3d05a8 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= <jordigh@octave.org>
Date: Mon, 5 Aug 2024 12:41:25 -0400
Subject: [PATCH 118/145] Activations: add an enabled_at column

For #1140, I considered trying to use the existing fields in a better
way, but because we already use the activations table to store
preferences, we need to keep all of the existing data and its usage
as-is.

The enterprise code will use this new column to decide how long the
trial period should be.
---
 app/gen-server/entity/Activation.ts            |  9 +++++++++
 .../1722529827161-Activation-Enabled.ts        | 18 ++++++++++++++++++
 test/gen-server/migrations.ts                  |  4 +++-
 3 files changed, 30 insertions(+), 1 deletion(-)
 create mode 100644 app/gen-server/migration/1722529827161-Activation-Enabled.ts

diff --git a/app/gen-server/entity/Activation.ts b/app/gen-server/entity/Activation.ts
index ca84c3f7..6507f99e 100644
--- a/app/gen-server/entity/Activation.ts
+++ b/app/gen-server/entity/Activation.ts
@@ -22,6 +22,15 @@ export class Activation extends BaseEntity {
   @Column({name: 'updated_at', default: () => "CURRENT_TIMESTAMP"})
   public updatedAt: Date;
 
+  // When the enterprise activation was first enabled, so we know when
+  // to start counting the trial date.
+  //
+  // Activations are created at Grist installation to track other
+  // things such as prefs, but the user might not enable Enterprise
+  // until later.
+  @Column({name: 'enabled_at', type: nativeValues.dateTimeType, nullable: true})
+  public enabledAt: Date|null;
+
   public checkProperties(props: any): props is Partial<InstallProperties> {
     for (const key of Object.keys(props)) {
       if (!installPropertyKeys.includes(key)) {
diff --git a/app/gen-server/migration/1722529827161-Activation-Enabled.ts b/app/gen-server/migration/1722529827161-Activation-Enabled.ts
new file mode 100644
index 00000000..611cf61a
--- /dev/null
+++ b/app/gen-server/migration/1722529827161-Activation-Enabled.ts
@@ -0,0 +1,18 @@
+import * as sqlUtils from "app/gen-server/sqlUtils";
+import { MigrationInterface, QueryRunner, TableColumn } from "typeorm";
+
+export class ActivationEnabled1722529827161 implements MigrationInterface {
+  public async up(queryRunner: QueryRunner): Promise<void> {
+    const dbType = queryRunner.connection.driver.options.type;
+    const datetime = sqlUtils.datetime(dbType);
+    await queryRunner.addColumn('activations', new TableColumn({
+      name: 'enabled_at',
+      type: datetime,
+      isNullable: true,
+    }));
+  }
+
+  public async down(queryRunner: QueryRunner): Promise<void> {
+    await queryRunner.dropColumn('activations', 'enabled_at');
+  }
+}
diff --git a/test/gen-server/migrations.ts b/test/gen-server/migrations.ts
index f3cc32df..bc8dd1b1 100644
--- a/test/gen-server/migrations.ts
+++ b/test/gen-server/migrations.ts
@@ -44,6 +44,8 @@ import {Shares1701557445716 as Shares} from 'app/gen-server/migration/1701557445
 import {Billing1711557445716 as BillingFeatures} from 'app/gen-server/migration/1711557445716-Billing';
 import {UserLastConnection1713186031023
         as UserLastConnection} from 'app/gen-server/migration/1713186031023-UserLastConnection';
+import {ActivationEnabled1722529827161
+        as ActivationEnabled} from 'app/gen-server/migration/1722529827161-Activation-Enabled';
 
 const home: HomeDBManager = new HomeDBManager();
 
@@ -53,7 +55,7 @@ const migrations = [Initial, Login, PinDocs, UserPicture, DisplayEmail, DisplayE
                     ExternalBilling, DocOptions, Secret, UserOptions, GracePeriodStart,
                     DocumentUsage, Activations, UserConnectId, UserUUID, UserUniqueRefUUID,
                     Forks, ForkIndexes, ActivationPrefs, AssistantLimit, Shares, BillingFeatures,
-                    UserLastConnection];
+                    UserLastConnection, ActivationEnabled];
 
 // Assert that the "members" acl rule and group exist (or not).
 function assertMembersGroup(org: Organization, exists: boolean) {

From be0de1852edc1a78663d758c5279d94f6340414c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=D0=92=D0=BB=D0=B0=D0=B4=D0=B8=D0=BC=D0=B8=D1=80=20=D0=92?=
 <vakukh@gmail.com>
Date: Wed, 7 Aug 2024 18:53:46 +0000
Subject: [PATCH 119/145] Translated using Weblate (Russian)

Currently translated at 99.4% (1371 of 1378 strings)

Translation: Grist/client
Translate-URL: https://hosted.weblate.org/projects/grist/client/ru/
---
 static/locales/ru.client.json | 48 ++++++++++++++++++++++++++++++++++-
 1 file changed, 47 insertions(+), 1 deletion(-)

diff --git a/static/locales/ru.client.json b/static/locales/ru.client.json
index 65f7758c..9ab0f014 100644
--- a/static/locales/ru.client.json
+++ b/static/locales/ru.client.json
@@ -1558,7 +1558,8 @@
         "Key to sign sessions with": "Ключ для подписи сеансов с",
         "Session Secret": "Session Secret",
         "Grist signs user session cookies with a secret key. Please set this key via the environment variable GRIST_SESSION_SECRET. Grist falls back to a hard-coded default when it is not set. We may remove this notice in the future since session IDs have been updated to be inherently cryptographically secure.": "Grist подписывает файлы cookie сеанса пользователя секретным ключом. Установите этот ключ через переменную среды GRIST_SESSION_SECRET. Grist возвращается к жестко запрограммированному значению по умолчанию, если оно не установлено. Мы можем удалить это уведомление в будущем, поскольку идентификаторы сеансов, созданные начиная с версии 1.1.16, по своей сути криптографически безопасны.",
-        "Grist signs user session cookies with a secret key. Please set this key via the environment variable GRIST_SESSION_SECRET. Grist falls back to a hard-coded default when it is not set. We may remove this notice in the future as session IDs generated since v1.1.16 are inherently cryptographically secure.": "Grist подписывает файлы cookie сеанса пользователя секретным ключом. Установите этот ключ через переменную среды GRIST_SESSION_SECRET. Grist возвращается к жестко запрограммированному значению по умолчанию, если оно не установлено. Мы можем удалить это уведомление в будущем, поскольку идентификаторы сеансов, созданные начиная с версии 1.1.16, по своей сути криптографически безопасны."
+        "Grist signs user session cookies with a secret key. Please set this key via the environment variable GRIST_SESSION_SECRET. Grist falls back to a hard-coded default when it is not set. We may remove this notice in the future as session IDs generated since v1.1.16 are inherently cryptographically secure.": "Grist подписывает файлы cookie сеанса пользователя секретным ключом. Установите этот ключ через переменную среды GRIST_SESSION_SECRET. Grist возвращается к жестко запрограммированному значению по умолчанию, если оно не установлено. Мы можем удалить это уведомление в будущем, поскольку идентификаторы сеансов, созданные начиная с версии 1.1.16, по своей сути криптографически безопасны.",
+        "Enable Grist Enterprise": "Включить Grist Enterprise"
     },
     "CreateTeamModal": {
         "Billing is not supported in grist-core": "Выставление счетов в grist-core не поддерживается",
@@ -1633,5 +1634,50 @@
         "Search": "Поиск",
         "Select...": "Выбрать...",
         "Submit": "Отправить"
+    },
+    "DocTutorial": {
+        "Click to expand": "Нажмите, чтобы развернуть",
+        "Finish": "Закончить",
+        "Do you want to restart the tutorial? All progress will be lost.": "Хотите перезапустить обучение? Весь прогресс будет потерян.",
+        "End tutorial": "Завершить обучение",
+        "Previous": "Предыдущий",
+        "Restart": "Перезапуск",
+        "Next": "Следующий"
+    },
+    "OnboardingCards": {
+        "Learn the basic of reference columns, linked widgets, column types, & cards.": "Изучите основы ссылочных столбцов, связанных виджетов, типов столбцов и карточек.",
+        "3 minute video tour": "3-минутный видеотур",
+        "Complete the tutorial": "Завершить обучение",
+        "Complete our basics tutorial": "Завершите наше базовое обучение"
+    },
+    "OnboardingPage": {
+        "Go hands-on with the Grist Basics tutorial": "Познакомьтесь с учебным пособием по основам Grist",
+        "Tell us who you are": "Расскажите нам, кто вы",
+        "Type here": "Введите здесь",
+        "Welcome": "Добро пожаловать",
+        "What brings you to Grist (you can select multiple)?": "Что привело вас в Grist (вы можете выбрать несколько)?",
+        "What is your role?": "Какова ваша роль?",
+        "What organization are you with?": "В какой организации вы работаете?",
+        "Your organization": "Ваша организация",
+        "Your role": "Ваша роль",
+        "Back": "Назад",
+        "Discover Grist in 3 minutes": "Откройте для себя Grist за 3 минуты",
+        "Go to the tutorial!": "Перейти к обучению!",
+        "Next step": "Следующий шаг",
+        "Skip step": "Пропустить шаг",
+        "Skip tutorial": "Пропустить обучение"
+    },
+    "ToggleEnterpriseWidget": {
+        "An activation key is used to run Grist Enterprise after a trial period\nof 30 days has expired. Get an activation key by [signing up for Grist\nEnterprise]({{signupLink}}). You do not need an activation key to run\nGrist Core.\n\nLearn more in our [Help Center]({{helpCenter}}).": "Ключ активации используется для запуска Grist Enterprise после окончания\n30 дневного пробного периода. Получите ключ активации по [подписавшись на Grist\nEnterprise]({{signupLink}}). Для запуска Grist Core вам не нужен ключ активации.\n\nУзнайте больше в нашем [Центр помощи]({{helpCenter}}).",
+        "Disable Grist Enterprise": "Отключить Grist Enterprise",
+        "Enable Grist Enterprise": "Включить Grist Enterprise",
+        "Grist Enterprise is **enabled**.": "Grist Enterprise **включен**."
+    },
+    "ViewLayout": {
+        "Delete": "Удалить",
+        "Delete data and this widget.": "Удалить данные и этот виджет.",
+        "Keep data and delete widget. Table will remain available in {{rawDataLink}}": "Сохранить данные и удалить виджет. Таблица останется доступной в {{rawDataLink}}",
+        "Table {{tableName}} will no longer be visible": "Таблица {{tableName}} больше не будет видима",
+        "raw data page": "Страница исходных данных"
     }
 }

From fde6c8142d0afb2b19a798ba0e49eedee0267021 Mon Sep 17 00:00:00 2001
From: Florent <florent.git@zeteo.me>
Date: Thu, 8 Aug 2024 21:35:37 +0200
Subject: [PATCH 120/145] Support nonce and acr with OIDC + other improvements
 and tests (#883)

* Introduces new configuration variables for OIDC:
  - GRIST_OIDC_IDP_ENABLED_PROTECTIONS
  - GRIST_OIDC_IDP_ACR_VALUES
  - GRIST_OIDC_IDP_EXTRA_CLIENT_METADATA
* Implements all supported protections in oidc/Protections.ts
* Includes a better error page for failed OIDC logins
* Includes some other improvements, e.g. to logging, to OIDC
* Adds a large unit test for OIDCConfig
* Adds support for SERVER_NODE_OPTIONS for running tests
* Adds to documentation/develop.md info about GREP_TESTS, VERBOSE, and SERVER_NODE_OPTIONS.
---
 app/client/ui/errorPages.ts        |  23 +-
 app/common/StringUnion.ts          |  10 +-
 app/server/lib/BrowserSession.ts   |  21 +-
 app/server/lib/FlexServer.ts       |  12 +-
 app/server/lib/OIDCConfig.ts       | 253 +++++++---
 app/server/lib/oidc/Protections.ts | 121 +++++
 app/server/lib/sendAppPage.ts      |  13 +-
 documentation/develop.md           |   7 +
 static/locales/en.server.json      |   3 +
 test/nbrowser/homeUtil.ts          |   2 +-
 test/nbrowser/testServer.ts        |   5 +-
 test/server/lib/OIDCConfig.ts      | 763 +++++++++++++++++++++++++++++
 12 files changed, 1149 insertions(+), 84 deletions(-)
 create mode 100644 app/server/lib/oidc/Protections.ts
 create mode 100644 test/server/lib/OIDCConfig.ts

diff --git a/app/client/ui/errorPages.ts b/app/client/ui/errorPages.ts
index b21ac106..26264bc1 100644
--- a/app/client/ui/errorPages.ts
+++ b/app/client/ui/errorPages.ts
@@ -15,12 +15,19 @@ const testId = makeTestId('test-');
 
 const t = makeT('errorPages');
 
+function signInAgainButton() {
+  return cssButtonWrap(bigPrimaryButtonLink(
+    t("Sign in again"), {href: getLoginUrl()}, testId('error-signin')
+  ));
+}
+
 export function createErrPage(appModel: AppModel) {
   const {errMessage, errPage} = getGristConfig();
   return errPage === 'signed-out' ? createSignedOutPage(appModel) :
     errPage === 'not-found' ? createNotFoundPage(appModel, errMessage) :
     errPage === 'access-denied' ? createForbiddenPage(appModel, errMessage) :
     errPage === 'account-deleted' ? createAccountDeletedPage(appModel) :
+    errPage === 'signin-failed' ? createSigninFailedPage(appModel, errMessage) :
     createOtherErrorPage(appModel, errMessage);
 }
 
@@ -61,9 +68,7 @@ export function createSignedOutPage(appModel: AppModel) {
 
   return pagePanelsError(appModel, t("Signed out{{suffix}}", {suffix: ''}), [
     cssErrorText(t("You are now signed out.")),
-    cssButtonWrap(bigPrimaryButtonLink(
-      t("Sign in again"), {href: getLoginUrl()}, testId('error-signin')
-    ))
+    signInAgainButton(),
   ]);
 }
 
@@ -98,6 +103,18 @@ export function createNotFoundPage(appModel: AppModel, message?: string) {
   ]);
 }
 
+export function createSigninFailedPage(appModel: AppModel, message?: string) {
+  document.title = t("Sign-in failed{{suffix}}", {suffix: getPageTitleSuffix(getGristConfig())});
+  return pagePanelsError(appModel, t("Sign-in failed{{suffix}}", {suffix: ''}), [
+    cssErrorText(message ??
+      t("Failed to log in.{{separator}}Please try again or contact support.", {
+        separator: dom('br')
+    })),
+    signInAgainButton(),
+    cssButtonWrap(bigBasicButtonLink(t("Contact support"), {href: commonUrls.contactSupport})),
+  ]);
+}
+
 /**
  * Creates a generic error page with the given message.
  */
diff --git a/app/common/StringUnion.ts b/app/common/StringUnion.ts
index c61eecd9..d14b27d4 100644
--- a/app/common/StringUnion.ts
+++ b/app/common/StringUnion.ts
@@ -1,3 +1,9 @@
+export class StringUnionError extends TypeError {
+  constructor(errMessage: string, public readonly actual: string, public readonly values: string[]) {
+    super(errMessage);
+  }
+}
+
 /**
  * TypeScript will infer a string union type from the literal values passed to
  * this function. Without `extends string`, it would instead generalize them
@@ -28,7 +34,7 @@ export const StringUnion = <UnionType extends string>(...values: UnionType[]) =>
     if (!guard(value)) {
       const actual = JSON.stringify(value);
       const expected = values.map(s => JSON.stringify(s)).join(' | ');
-      throw new TypeError(`Value '${actual}' is not assignable to type '${expected}'.`);
+      throw new StringUnionError(`Value '${actual}' is not assignable to type '${expected}'.`, actual, values);
     }
     return value;
   };
@@ -44,6 +50,6 @@ export const StringUnion = <UnionType extends string>(...values: UnionType[]) =>
     return value != null && guard(value) ? value : undefined;
   };
 
-  const unionNamespace = {guard, check, parse, values, checkAll};
+  const unionNamespace = { guard, check, parse, values, checkAll };
   return Object.freeze(unionNamespace as typeof unionNamespace & {type: UnionType});
 };
diff --git a/app/server/lib/BrowserSession.ts b/app/server/lib/BrowserSession.ts
index 07136e3e..a54aecac 100644
--- a/app/server/lib/BrowserSession.ts
+++ b/app/server/lib/BrowserSession.ts
@@ -69,12 +69,21 @@ export interface SessionObj {
                           // something they just added, without allowing the suer
                           // to edit other people's contributions).
 
-  oidc?: {
-    // codeVerifier is used during OIDC authentication, to protect against attacks like CSRF.
-    codeVerifier?: string;
-    state?: string;
-    targetUrl?: string;
-  }
+  oidc?: SessionOIDCInfo;
+}
+
+export interface SessionOIDCInfo {
+  // more details on protections are available here: https://danielfett.de/2020/05/16/pkce-vs-nonce-equivalent-or-not/#special-case-error-responses
+  // code_verifier is used during OIDC authentication for PKCE protection, to protect against attacks like CSRF.
+  // PKCE + state are currently the best combination to protect against CSRF and code injection attacks.
+  code_verifier?: string;
+  // much like code_verifier, for OIDC providers that do not support PKCE.
+  nonce?: string;
+  // state is used to protect against Error Responses spoofs.
+  state?: string;
+  targetUrl?: string;
+  // Stores user claims signed by the issuer, store it to allow loging out.
+  idToken?: string;
 }
 
 // Make an artificial change to a session to encourage express-session to set a cookie.
diff --git a/app/server/lib/FlexServer.ts b/app/server/lib/FlexServer.ts
index 240d8195..37070d98 100644
--- a/app/server/lib/FlexServer.ts
+++ b/app/server/lib/FlexServer.ts
@@ -1036,7 +1036,7 @@ export class FlexServer implements GristServer {
       server: this,
       staticDir: getAppPathTo(this.appRoot, 'static'),
       tag: this.tag,
-      testLogin: allowTestLogin(),
+      testLogin: isTestLoginAllowed(),
       baseDomain: this._defaultBaseDomain,
     });
 
@@ -1207,7 +1207,7 @@ export class FlexServer implements GristServer {
     })));
     this.app.get('/signin', ...signinMiddleware, expressWrap(this._redirectToLoginOrSignup.bind(this, {})));
 
-    if (allowTestLogin()) {
+    if (isTestLoginAllowed()) {
       // This is an endpoint for the dev environment that lets you log in as anyone.
       // For a standard dev environment, it will be accessible at localhost:8080/test/login
       // and localhost:8080/o/<org>/test/login.  Only available when GRIST_TEST_LOGIN is set.
@@ -1989,7 +1989,9 @@ export class FlexServer implements GristServer {
   }
 
   public resolveLoginSystem() {
-    return process.env.GRIST_TEST_LOGIN ? getTestLoginSystem() : (this._getLoginSystem?.() || getLoginSystem());
+    return isTestLoginAllowed() ?
+      getTestLoginSystem() :
+      (this._getLoginSystem?.() || getLoginSystem());
   }
 
   public addUpdatesCheck() {
@@ -2519,8 +2521,8 @@ function configServer<T extends https.Server|http.Server>(server: T): T {
 }
 
 // Returns true if environment is configured to allow unauthenticated test logins.
-function allowTestLogin() {
-  return Boolean(process.env.GRIST_TEST_LOGIN);
+function isTestLoginAllowed() {
+  return isAffirmative(process.env.GRIST_TEST_LOGIN);
 }
 
 // Check OPTIONS requests for allowed origins, and return heads to allow the browser to proceed
diff --git a/app/server/lib/OIDCConfig.ts b/app/server/lib/OIDCConfig.ts
index 86f78bce..8a353545 100644
--- a/app/server/lib/OIDCConfig.ts
+++ b/app/server/lib/OIDCConfig.ts
@@ -35,6 +35,19 @@
  *    env GRIST_OIDC_SP_IGNORE_EMAIL_VERIFIED
  *        If set to "true", the user will be allowed to login even if the email is not verified by the IDP.
  *        Defaults to false.
+ *    env GRIST_OIDC_IDP_ENABLED_PROTECTIONS
+ *        A comma-separated list of protections to enable. Supported values are "PKCE", "STATE", "NONCE"
+ *        (or you may set it to "UNPROTECTED" alone, to disable any protections if you *really* know what you do!).
+ *        Defaults to "PKCE,STATE", which is the recommended settings.
+ *        It's highly recommended that you enable STATE, and at least one of PKCE or NONCE,
+ *        depending on what your OIDC provider requires/supports.
+ *    env GRIST_OIDC_IDP_ACR_VALUES
+ *        A space-separated list of ACR values to request from the IdP. Optional.
+ *    env GRIST_OIDC_IDP_EXTRA_CLIENT_METADATA
+ *        A JSON object with extra client metadata to pass to openid-client. Optional.
+ *        Be aware that setting this object may override any other values passed to the openid client.
+ *        More info: https://github.com/panva/node-openid-client/tree/main/docs#new-clientmetadata-jwks-options
+ *
  *
  * This version of OIDCConfig has been tested with Keycloak OIDC IdP following the instructions
  * at:
@@ -52,26 +65,61 @@
 
 import * as express from 'express';
 import { GristLoginSystem, GristServer } from './GristServer';
-import { Client, generators, Issuer, UserinfoResponse } from 'openid-client';
+import {
+  Client, ClientMetadata, Issuer, errors as OIDCError, TokenSet, UserinfoResponse
+} from 'openid-client';
 import { Sessions } from './Sessions';
 import log from 'app/server/lib/log';
-import { appSettings } from './AppSettings';
+import { AppSettings, appSettings } from './AppSettings';
 import { RequestWithLogin } from './Authorizer';
 import { UserProfile } from 'app/common/LoginSessionAPI';
+import { SendAppPageFunction } from 'app/server/lib/sendAppPage';
+import { StringUnionError } from 'app/common/StringUnion';
+import { EnabledProtection, EnabledProtectionString, ProtectionsManager } from './oidc/Protections';
+import { SessionObj } from './BrowserSession';
 
 const CALLBACK_URL = '/oauth2/callback';
 
+function formatTokenForLogs(token: TokenSet) {
+  const showValueInClear = ['token_type', 'expires_in', 'expires_at', 'scope'];
+  const result: Record<string, any> = {};
+  for (const [key, value] of Object.entries(token)) {
+    if (typeof value !== 'function') {
+      result[key] = showValueInClear.includes(key) ? value : 'REDACTED';
+    }
+  }
+  return result;
+}
+
+class ErrorWithUserFriendlyMessage extends Error {
+  constructor(errMessage: string, public readonly userFriendlyMessage: string) {
+    super(errMessage);
+  }
+}
+
 export class OIDCConfig {
-  private _client: Client;
+  /**
+   * Handy alias to create an OIDCConfig instance and initialize it.
+   */
+  public static async build(sendAppPage: SendAppPageFunction): Promise<OIDCConfig> {
+    const config = new OIDCConfig(sendAppPage);
+    await config.initOIDC();
+    return config;
+  }
+
+  protected _client: Client;
   private _redirectUrl: string;
   private _namePropertyKey?: string;
   private _emailPropertyKey: string;
   private _endSessionEndpoint: string;
   private _skipEndSessionEndpoint: boolean;
   private _ignoreEmailVerified: boolean;
+  private _protectionManager: ProtectionsManager;
+  private _acrValues?: string;
 
-  public constructor() {
-  }
+  protected constructor(
+    private _sendAppPage: SendAppPageFunction
+  ) {}
 
   public async initOIDC(): Promise<void> {
     const section = appSettings.section('login').section('system').section('oidc');
@@ -108,21 +156,27 @@ export class OIDCConfig {
       defaultValue: false,
     })!;
 
+    this._acrValues = section.flag('acrValues').readString({
+      envVar: 'GRIST_OIDC_IDP_ACR_VALUES',
+    })!;
+
     this._ignoreEmailVerified = section.flag('ignoreEmailVerified').readBool({
       envVar: 'GRIST_OIDC_SP_IGNORE_EMAIL_VERIFIED',
       defaultValue: false,
     })!;
 
-    const issuer = await Issuer.discover(issuerUrl);
+    const extraMetadata: Partial<ClientMetadata> = JSON.parse(section.flag('extraClientMetadata').readString({
+      envVar: 'GRIST_OIDC_IDP_EXTRA_CLIENT_METADATA',
+    }) || '{}');
+
+    const enabledProtections = this._buildEnabledProtections(section);
+    this._protectionManager = new ProtectionsManager(enabledProtections);
+
     this._redirectUrl = new URL(CALLBACK_URL, spHost).href;
-    this._client = new issuer.Client({
-      client_id: clientId,
-      client_secret: clientSecret,
-      redirect_uris: [ this._redirectUrl ],
-      response_types: [ 'code' ],
-    });
+    await this._initClient({ issuerUrl, clientId, clientSecret, extraMetadata });
+
     if (this._client.issuer.metadata.end_session_endpoint === undefined &&
-        !this._endSessionEndpoint && !this._skipEndSessionEndpoint) {
+      !this._endSessionEndpoint && !this._skipEndSessionEndpoint) {
       throw new Error('The Identity provider does not propose end_session_endpoint. ' +
         'If that is expected, please set GRIST_OIDC_IDP_SKIP_END_SESSION_ENDPOINT=true ' +
         'or provide an alternative logout URL in GRIST_OIDC_IDP_END_SESSION_ENDPOINT');
@@ -135,28 +189,37 @@ export class OIDCConfig {
   }
 
   public async handleCallback(sessions: Sessions, req: express.Request, res: express.Response): Promise<void> {
-    const mreq = req as RequestWithLogin;
+    let mreq;
+    try {
+      mreq = this._getRequestWithSession(req);
+    } catch(err) {
+      log.warn("OIDCConfig callback:", err.message);
+      return this._sendErrorPage(req, res);
+    }
+
     try {
       const params = this._client.callbackParams(req);
-      const { state, targetUrl } = mreq.session?.oidc ?? {};
-      if (!state) {
-        throw new Error('Login or logout failed to complete');
+      if (!mreq.session.oidc) {
+        throw new Error('Missing OIDC information associated to this session');
       }
 
-      const codeVerifier = await this._retrieveCodeVerifierFromSession(req);
+      const { targetUrl } = mreq.session.oidc;
 
-      // The callback function will compare the state present in the params and the one we retrieved from the session.
-      // If they don't match, it will throw an error.
-      const tokenSet = await this._client.callback(
-        this._redirectUrl,
-        params,
-        { state, code_verifier: codeVerifier }
-      );
+      const checks = this._protectionManager.getCallbackChecks(mreq.session.oidc);
+
+      // The callback function will compare the protections present in the params and the ones we retrieved
+      // from the session. If they don't match, it will throw an error.
+      const tokenSet = await this._client.callback(this._redirectUrl, params, checks);
+      log.debug("Got tokenSet: %o", formatTokenForLogs(tokenSet));
 
       const userInfo = await this._client.userinfo(tokenSet);
+      log.debug("Got userinfo: %o", userInfo);
 
       if (!this._ignoreEmailVerified && userInfo.email_verified !== true) {
-        throw new Error(`OIDCConfig: email not verified for ${userInfo.email}`);
+        throw new ErrorWithUserFriendlyMessage(
+          `OIDCConfig: email not verified for ${userInfo.email}`,
+          req.t("oidc.emailNotVerifiedError")
+        );
       }
 
       const profile = this._makeUserProfileFromUserInfo(userInfo);
@@ -167,33 +230,47 @@ export class OIDCConfig {
         profile,
       }));
 
-      delete mreq.session.oidc;
+      // We clear the previous session info, like the states, nonce or the code verifier, which
+      // now that we are authenticated.
+      // We store the idToken for later, especially for the logout
+      mreq.session.oidc = {
+        idToken: tokenSet.id_token,
+      };
       res.redirect(targetUrl ?? '/');
     } catch (err) {
       log.error(`OIDC callback failed: ${err.stack}`);
-      // Delete the session data even if the login failed.
+      const maybeResponse = this._maybeExtractDetailsFromError(err);
+      if (maybeResponse) {
+        log.error('Response received: %o',  maybeResponse);
+      }
+
+      // Delete entirely the session data when the login failed.
       // This way, we prevent several login attempts.
       //
       // Also session deletion must be done before sending the response.
       delete mreq.session.oidc;
-      res.status(500).send(`OIDC callback failed.`);
+
+      await this._sendErrorPage(req, res, err.userFriendlyMessage);
     }
   }
 
   public async getLoginRedirectUrl(req: express.Request, targetUrl: URL): Promise<string> {
-    const { codeVerifier, state } = await this._generateAndStoreConnectionInfo(req, targetUrl.href);
-    const codeChallenge = generators.codeChallenge(codeVerifier);
+    const mreq = this._getRequestWithSession(req);
 
-    const authUrl = this._client.authorizationUrl({
+    mreq.session.oidc = {
+      targetUrl: targetUrl.href,
+      ...this._protectionManager.generateSessionInfo()
+    };
+
+    return this._client.authorizationUrl({
       scope: process.env.GRIST_OIDC_IDP_SCOPES || 'openid email profile',
-      code_challenge: codeChallenge,
-      code_challenge_method: 'S256',
-      state,
+      acr_values: this._acrValues,
+      ...this._protectionManager.forgeAuthUrlParams(mreq.session.oidc),
     });
-    return authUrl;
   }
 
   public async getLogoutRedirectUrl(req: express.Request, redirectUrl: URL): Promise<string> {
+    const session: SessionObj|undefined = (req as RequestWithLogin).session;
     // For IdPs that don't have end_session_endpoint, we just redirect to the logout page.
     if (this._skipEndSessionEndpoint) {
       return redirectUrl.href;
@@ -203,56 +280,112 @@ export class OIDCConfig {
       return this._endSessionEndpoint;
     }
     return this._client.endSessionUrl({
-      post_logout_redirect_uri: redirectUrl.href
+      post_logout_redirect_uri: redirectUrl.href,
+      id_token_hint: session?.oidc?.idToken,
     });
   }
 
-  private async _generateAndStoreConnectionInfo(req: express.Request, targetUrl: string) {
-    const mreq = req as RequestWithLogin;
-    if (!mreq.session) { throw new Error('no session available'); }
-    const codeVerifier = generators.codeVerifier();
-    const state = generators.state();
-    mreq.session.oidc = {
-      codeVerifier,
-      state,
-      targetUrl
-    };
-
-    return { codeVerifier, state };
+  public supportsProtection(protection: EnabledProtectionString) {
+    return this._protectionManager.supportsProtection(protection);
   }
 
-  private async _retrieveCodeVerifierFromSession(req: express.Request) {
+  protected async _initClient({ issuerUrl, clientId, clientSecret, extraMetadata }:
+    { issuerUrl: string, clientId: string, clientSecret: string, extraMetadata: Partial<ClientMetadata> }
+  ): Promise<void> {
+    const issuer = await Issuer.discover(issuerUrl);
+    this._client = new issuer.Client({
+      client_id: clientId,
+      client_secret: clientSecret,
+      redirect_uris: [this._redirectUrl],
+      response_types: ['code'],
+      ...extraMetadata,
+    });
+  }
+
+  private _sendErrorPage(req: express.Request, res: express.Response, userFriendlyMessage?: string) {
+    return this._sendAppPage(req, res, {
+      path: 'error.html',
+      status: 500,
+      config: {
+        errPage: 'signin-failed',
+        errMessage: userFriendlyMessage
+      },
+    });
+  }
+
+  private _getRequestWithSession(req: express.Request) {
     const mreq = req as RequestWithLogin;
     if (!mreq.session) { throw new Error('no session available'); }
-    const codeVerifier = mreq.session.oidc?.codeVerifier;
-    if (!codeVerifier) { throw new Error('Login is stale'); }
-    return codeVerifier;
+
+    return mreq;
+  }
+
+  private _buildEnabledProtections(section: AppSettings): Set<EnabledProtectionString> {
+    const enabledProtections = section.flag('enabledProtections').readString({
+      envVar: 'GRIST_OIDC_IDP_ENABLED_PROTECTIONS',
+      defaultValue: 'PKCE,STATE',
+    })!.split(',');
+    if (enabledProtections.length === 1 && enabledProtections[0] === 'UNPROTECTED') {
+      log.warn("You chose to enable OIDC connection with no protection, you are exposed to vulnerabilities." +
+        " Please never do that in production.");
+      return new Set();
+    }
+    try {
+      return new Set(EnabledProtection.checkAll(enabledProtections));
+    } catch (e) {
+      if (e instanceof StringUnionError) {
+        throw new TypeError(`OIDC: Invalid protection in GRIST_OIDC_IDP_ENABLED_PROTECTIONS: ${e.actual}.`+
+          ` Expected at least one of these values: "${e.values.join(",")}"`
+        );
+      }
+      throw e;
+    }
   }
 
   private _makeUserProfileFromUserInfo(userInfo: UserinfoResponse): Partial<UserProfile> {
     return {
-      email: String(userInfo[ this._emailPropertyKey ]),
+      email: String(userInfo[this._emailPropertyKey]),
       name: this._extractName(userInfo)
     };
   }
 
-  private _extractName(userInfo: UserinfoResponse): string|undefined {
+  private _extractName(userInfo: UserinfoResponse): string | undefined {
     if (this._namePropertyKey) {
-      return (userInfo[ this._namePropertyKey ] as any)?.toString();
+      return (userInfo[this._namePropertyKey] as any)?.toString();
     }
     const fname = userInfo.given_name ?? '';
     const lname = userInfo.family_name ?? '';
 
     return `${fname} ${lname}`.trim() || userInfo.name;
   }
+
+  /**
+   * Returns some response details from either OIDCClient's RPError or OPError,
+   * which are handy for error logging.
+   */
+  private _maybeExtractDetailsFromError(error: Error) {
+    if (error instanceof OIDCError.OPError || error instanceof OIDCError.RPError) {
+      const { response } = error;
+      if (response) {
+        // Ensure that we don't log a buffer (which might be noisy), at least for now, unless we're sure that
+        // would be relevant.
+        const isBodyPureObject = response.body && Object.getPrototypeOf(response.body) === Object.prototype;
+        return {
+          body: isBodyPureObject ? response.body : undefined,
+          statusCode: response.statusCode,
+          statusMessage: response.statusMessage,
+        };
+      }
+    }
+    return null;
+  }
 }
 
-export async function getOIDCLoginSystem(): Promise<GristLoginSystem|undefined> {
+export async function getOIDCLoginSystem(): Promise<GristLoginSystem | undefined> {
   if (!process.env.GRIST_OIDC_IDP_ISSUER) { return undefined; }
   return {
     async getMiddleware(gristServer: GristServer) {
-      const config = new OIDCConfig();
-      await config.initOIDC();
+      const config = await OIDCConfig.build(gristServer.sendAppPage.bind(gristServer));
       return {
         getLoginRedirectUrl: config.getLoginRedirectUrl.bind(config),
         getSignUpRedirectUrl: config.getLoginRedirectUrl.bind(config),
@@ -263,6 +396,6 @@ export async function getOIDCLoginSystem(): Promise<GristLoginSystem|undefined>
         },
       };
     },
-    async deleteUser() {},
+    async deleteUser() { },
   };
 }
diff --git a/app/server/lib/oidc/Protections.ts b/app/server/lib/oidc/Protections.ts
new file mode 100644
index 00000000..42070f8d
--- /dev/null
+++ b/app/server/lib/oidc/Protections.ts
@@ -0,0 +1,121 @@
+import { StringUnion } from 'app/common/StringUnion';
+import { SessionOIDCInfo } from 'app/server/lib/BrowserSession';
+import { AuthorizationParameters, generators, OpenIDCallbackChecks } from 'openid-client';
+
+export const EnabledProtection = StringUnion(
+  "STATE",
+  "NONCE",
+  "PKCE",
+);
+export type EnabledProtectionString = typeof EnabledProtection.type;
+
+interface Protection {
+  generateSessionInfo(): SessionOIDCInfo;
+  forgeAuthUrlParams(sessionInfo: SessionOIDCInfo): AuthorizationParameters;
+  getCallbackChecks(sessionInfo: SessionOIDCInfo): OpenIDCallbackChecks;
+}
+
+function checkIsSet(value: string|undefined, message: string): string {
+  if (!value) { throw new Error(message); }
+  return value;
+}
+
+class PKCEProtection implements Protection {
+  public generateSessionInfo(): SessionOIDCInfo {
+    return {
+      code_verifier: generators.codeVerifier()
+    };
+  }
+  public forgeAuthUrlParams(sessionInfo: SessionOIDCInfo): AuthorizationParameters {
+    return {
+      code_challenge: generators.codeChallenge(checkIsSet(sessionInfo.code_verifier, "Login is stale")),
+      code_challenge_method: 'S256'
+    };
+  }
+  public getCallbackChecks(sessionInfo: SessionOIDCInfo): OpenIDCallbackChecks {
+    return {
+      code_verifier: checkIsSet(sessionInfo.code_verifier, "Login is stale")
+    };
+  }
+}
+
+class NonceProtection implements Protection {
+  public generateSessionInfo(): SessionOIDCInfo {
+    return {
+      nonce: generators.nonce()
+    };
+  }
+  public forgeAuthUrlParams(sessionInfo: SessionOIDCInfo): AuthorizationParameters {
+    return {
+      nonce: sessionInfo.nonce
+    };
+  }
+  public getCallbackChecks(sessionInfo: SessionOIDCInfo): OpenIDCallbackChecks {
+    return {
+      nonce: checkIsSet(sessionInfo.nonce, "Login is stale")
+    };
+  }
+}
+
+class StateProtection implements Protection {
+  public generateSessionInfo(): SessionOIDCInfo {
+    return {
+      state: generators.state()
+    };
+  }
+  public forgeAuthUrlParams(sessionInfo: SessionOIDCInfo): AuthorizationParameters {
+    return {
+      state: sessionInfo.state
+    };
+  }
+  public getCallbackChecks(sessionInfo: SessionOIDCInfo): OpenIDCallbackChecks {
+    return {
+      state: checkIsSet(sessionInfo.state, "Login or logout failed to complete")
+    };
+  }
+}
+
+export class ProtectionsManager implements Protection {
+  private _protections: Protection[] = [];
+
+  constructor(private _enabledProtections: Set<EnabledProtectionString>) {
+    if (this._enabledProtections.has('STATE')) {
+      this._protections.push(new StateProtection());
+    }
+    if (this._enabledProtections.has('NONCE')) {
+      this._protections.push(new NonceProtection());
+    }
+    if (this._enabledProtections.has('PKCE')) {
+      this._protections.push(new PKCEProtection());
+    }
+  }
+
+  public generateSessionInfo(): SessionOIDCInfo {
+    const sessionInfo: SessionOIDCInfo = {};
+    for (const protection of this._protections) {
+      Object.assign(sessionInfo, protection.generateSessionInfo());
+    }
+    return sessionInfo;
+  }
+
+  public forgeAuthUrlParams(sessionInfo: SessionOIDCInfo): AuthorizationParameters {
+    const authParams: AuthorizationParameters = {};
+    for (const protection of this._protections) {
+      Object.assign(authParams, protection.forgeAuthUrlParams(sessionInfo));
+    }
+    return authParams;
+  }
+
+  public getCallbackChecks(sessionInfo: SessionOIDCInfo): OpenIDCallbackChecks {
+    const checks: OpenIDCallbackChecks = {};
+    for (const protection of this._protections) {
+      Object.assign(checks, protection.getCallbackChecks(sessionInfo));
+    }
+    return checks;
+  }
+
+  public supportsProtection(protection: EnabledProtectionString) {
+    return this._enabledProtections.has(protection);
+  }
+}
+
diff --git a/app/server/lib/sendAppPage.ts b/app/server/lib/sendAppPage.ts
index 460137fa..653028a7 100644
--- a/app/server/lib/sendAppPage.ts
+++ b/app/server/lib/sendAppPage.ts
@@ -121,15 +121,16 @@ export function makeMessagePage(staticDir: string) {
   };
 }
 
+export type SendAppPageFunction =
+  (req: express.Request, resp: express.Response, options: ISendAppPageOptions) => Promise<void>;
+
 /**
  * Send a simple template page, read from file at pagePath (relative to static/), with certain
  * placeholders replaced.
  */
-export function makeSendAppPage(opts: {
-  server: GristServer, staticDir: string, tag: string, testLogin?: boolean,
-  baseDomain?: string
-}) {
-  const {server, staticDir, tag, testLogin} = opts;
+export function makeSendAppPage({ server, staticDir, tag, testLogin, baseDomain }: {
+  server: GristServer, staticDir: string, tag: string, testLogin?: boolean, baseDomain?: string
+}): SendAppPageFunction {
 
   // If env var GRIST_INCLUDE_CUSTOM_SCRIPT_URL is set, load it in a <script> tag on all app pages.
   const customScriptUrl = process.env.GRIST_INCLUDE_CUSTOM_SCRIPT_URL;
@@ -140,7 +141,7 @@ export function makeSendAppPage(opts: {
     const config = makeGristConfig({
       homeUrl: !isSingleUserMode() ? server.getHomeUrl(req) : null,
       extra: options.config,
-      baseDomain: opts.baseDomain,
+      baseDomain,
       req,
       server,
     });
diff --git a/documentation/develop.md b/documentation/develop.md
index 4595b60c..4a7afb36 100644
--- a/documentation/develop.md
+++ b/documentation/develop.md
@@ -122,6 +122,13 @@ You may run the tests using one of these commands:
  - `yarn test:docker` to run some end-to-end tests under docker
  - `yarn test:python` to run the data engine tests
 
+Also some options that may interest you:
+ - `GREP_TESTS="pattern"` in order to filter the tests to run, for example: `GREP_TESTS="Boot" yarn test:nbrowser`
+ - `VERBOSE=1` in order to view logs when a server is spawned (especially useful to debug the end-to-end and backend tests)
+ - `SERVER_NODE_OPTIONS="node options"` in order to pass options to the server being tested,
+   for example: `SERVER_NODE_OPTIONS="--inspect --inspect-brk" GREP_TESTS="Boot" yarn test:nbrowser` 
+   to run the tests with the debugger (you should close the debugger each time the node process should stop)
+
 ## Develop widgets
 
 Check out this repository: https://github.com/gristlabs/grist-widget#readme
diff --git a/static/locales/en.server.json b/static/locales/en.server.json
index ce247b17..3023c47a 100644
--- a/static/locales/en.server.json
+++ b/static/locales/en.server.json
@@ -1,5 +1,8 @@
 {
   "sendAppPage": {
     "Loading": "Loading"
+  },
+  "oidc": {
+    "emailNotVerifiedError": "Please verify your email with the identity provider, and log in again."
   }
 }
diff --git a/test/nbrowser/homeUtil.ts b/test/nbrowser/homeUtil.ts
index b5edccd4..3aa2ff37 100644
--- a/test/nbrowser/homeUtil.ts
+++ b/test/nbrowser/homeUtil.ts
@@ -381,7 +381,7 @@ export class HomeUtil {
   /**
    * Waits for browser to navigate to a Grist login page.
    */
-   public async checkGristLoginPage(waitMs: number = 2000) {
+  public async checkGristLoginPage(waitMs: number = 2000) {
     await this.driver.wait(this.isOnGristLoginPage.bind(this), waitMs);
   }
 
diff --git a/test/nbrowser/testServer.ts b/test/nbrowser/testServer.ts
index 4928a253..11fded01 100644
--- a/test/nbrowser/testServer.ts
+++ b/test/nbrowser/testServer.ts
@@ -152,7 +152,10 @@ export class TestServerMerged extends EventEmitter implements IMochaServer {
       delete env.DOC_WORKER_COUNT;
     }
     this._server = spawn('node', [cmd], {
-      env,
+      env: {
+        ...env,
+        ...(process.env.SERVER_NODE_OPTIONS ? {NODE_OPTIONS: process.env.SERVER_NODE_OPTIONS} : {})
+      },
       stdio: quiet ? 'ignore' : ['inherit', serverLog, serverLog],
     });
     this._exitPromise = exitPromise(this._server);
diff --git a/test/server/lib/OIDCConfig.ts b/test/server/lib/OIDCConfig.ts
new file mode 100644
index 00000000..a7bb2d65
--- /dev/null
+++ b/test/server/lib/OIDCConfig.ts
@@ -0,0 +1,763 @@
+import {EnvironmentSnapshot} from "../testUtils";
+import {OIDCConfig} from "app/server/lib/OIDCConfig";
+import {SessionObj} from "app/server/lib/BrowserSession";
+import {Sessions} from "app/server/lib/Sessions";
+import log from "app/server/lib/log";
+import {assert} from "chai";
+import Sinon from "sinon";
+import {Client, generators, errors as OIDCError} from "openid-client";
+import express from "express";
+import _ from "lodash";
+import {RequestWithLogin} from "app/server/lib/Authorizer";
+import { SendAppPageFunction } from "app/server/lib/sendAppPage";
+
+const NOOPED_SEND_APP_PAGE: SendAppPageFunction = () => Promise.resolve();
+
+class OIDCConfigStubbed extends OIDCConfig {
+  public static async buildWithStub(client: Client = new ClientStub().asClient()) {
+    return this.build(NOOPED_SEND_APP_PAGE, client);
+  }
+  public static async build(sendAppPage: SendAppPageFunction, clientStub?: Client): Promise<OIDCConfigStubbed> {
+    const result = new OIDCConfigStubbed(sendAppPage);
+    if (clientStub) {
+      result._initClient = Sinon.spy(() => {
+        result._client = clientStub!;
+      });
+    }
+    await result.initOIDC();
+    return result;
+  }
+
+  public _initClient: Sinon.SinonSpy;
+}
+
+class ClientStub {
+  public static FAKE_REDIRECT_URL = 'FAKE_REDIRECT_URL';
+  public authorizationUrl = Sinon.stub().returns(ClientStub.FAKE_REDIRECT_URL);
+  public callbackParams = Sinon.stub().returns(undefined);
+  public callback = Sinon.stub().returns({});
+  public userinfo = Sinon.stub().returns(undefined);
+  public endSessionUrl = Sinon.stub().returns(undefined);
+  public issuer: {
+    metadata: {
+      end_session_endpoint: string | undefined;
+    }
+  } = {
+    metadata: {
+      end_session_endpoint: 'http://localhost:8484/logout',
+    }
+  };
+  public asClient() {
+    return this as unknown as Client;
+  }
+  public getAuthorizationUrlStub() {
+    return this.authorizationUrl;
+  }
+}
+
+describe('OIDCConfig', () => {
+  let oldEnv: EnvironmentSnapshot;
+  let sandbox: Sinon.SinonSandbox;
+  let logInfoStub: Sinon.SinonStub;
+  let logErrorStub: Sinon.SinonStub;
+  let logWarnStub: Sinon.SinonStub;
+  let logDebugStub: Sinon.SinonStub;
+
+  before(() => {
+    oldEnv = new EnvironmentSnapshot();
+  });
+
+  beforeEach(() => {
+    sandbox = Sinon.createSandbox();
+    logInfoStub = sandbox.stub(log, 'info');
+    logErrorStub = sandbox.stub(log, 'error');
+    logDebugStub = sandbox.stub(log, 'debug');
+    logWarnStub = sandbox.stub(log, 'warn');
+  });
+
+  afterEach(() => {
+    oldEnv.restore();
+    sandbox.restore();
+  });
+
+  function setEnvVars() {
+    // Prevent any environment variable from leaking into the test:
+    for (const envVar in process.env) {
+      if (envVar.startsWith('GRIST_OIDC_')) {
+        delete process.env[envVar];
+      }
+    }
+    process.env.GRIST_OIDC_SP_HOST = 'http://localhost:8484';
+    process.env.GRIST_OIDC_IDP_CLIENT_ID = 'client id';
+    process.env.GRIST_OIDC_IDP_CLIENT_SECRET = 'secret';
+    process.env.GRIST_OIDC_IDP_ISSUER = 'http://localhost:8000';
+  }
+
+  describe('build', () => {
+    function isInitializedLogCalled() {
+      return logInfoStub.calledWithExactly(`OIDCConfig: initialized with issuer ${process.env.GRIST_OIDC_IDP_ISSUER}`);
+    }
+
+    it('should reject when required env variables are not passed', async () => {
+      for (const envVar of [
+        'GRIST_OIDC_SP_HOST',
+        'GRIST_OIDC_IDP_ISSUER',
+        'GRIST_OIDC_IDP_CLIENT_ID',
+        'GRIST_OIDC_IDP_CLIENT_SECRET',
+      ]) {
+        setEnvVars();
+        delete process.env[envVar];
+        const promise = OIDCConfig.build(NOOPED_SEND_APP_PAGE);
+        await assert.isRejected(promise, `missing environment variable: ${envVar}`);
+      }
+    });
+
+    it('should reject when the client initialization fails', async () => {
+      setEnvVars();
+      sandbox.stub(OIDCConfigStubbed.prototype, '_initClient').rejects(new Error('client init failed'));
+      const promise = OIDCConfigStubbed.build(NOOPED_SEND_APP_PAGE);
+      await assert.isRejected(promise, 'client init failed');
+    });
+
+    it('should create a client with passed information', async () => {
+      setEnvVars();
+      const config = await OIDCConfigStubbed.buildWithStub();
+      assert.isTrue(config._initClient.calledOnce);
+      assert.deepEqual(config._initClient.firstCall.args, [{
+        clientId: process.env.GRIST_OIDC_IDP_CLIENT_ID,
+        clientSecret: process.env.GRIST_OIDC_IDP_CLIENT_SECRET,
+        issuerUrl: process.env.GRIST_OIDC_IDP_ISSUER,
+        extraMetadata: {},
+      }]);
+
+      assert.isTrue(isInitializedLogCalled());
+    });
+
+    it('should create a client with passed information with extra configuration', async () => {
+      setEnvVars();
+      const extraMetadata = {
+        userinfo_signed_response_alg: 'RS256',
+      };
+      process.env.GRIST_OIDC_IDP_EXTRA_CLIENT_METADATA = JSON.stringify(extraMetadata);
+      const config = await OIDCConfigStubbed.buildWithStub();
+      assert.isTrue(config._initClient.calledOnce);
+      assert.deepEqual(config._initClient.firstCall.args, [{
+        clientId: process.env.GRIST_OIDC_IDP_CLIENT_ID,
+        clientSecret: process.env.GRIST_OIDC_IDP_CLIENT_SECRET,
+        issuerUrl: process.env.GRIST_OIDC_IDP_ISSUER,
+        extraMetadata,
+      }]);
+    });
+
+    describe('End Session Endpoint', () => {
+      [
+        {
+          itMsg: 'should fulfill when the end_session_endpoint is not known ' +
+            'and GRIST_OIDC_IDP_SKIP_END_SESSION_ENDPOINT=true',
+          end_session_endpoint: undefined,
+          env: {
+            GRIST_OIDC_IDP_SKIP_END_SESSION_ENDPOINT: 'true'
+          }
+        },
+        {
+          itMsg: 'should fulfill when the end_session_endpoint is provided with GRIST_OIDC_IDP_END_SESSION_ENDPOINT',
+          end_session_endpoint: undefined,
+          env: {
+            GRIST_OIDC_IDP_END_SESSION_ENDPOINT: 'http://localhost:8484/logout'
+          }
+        },
+        {
+          itMsg: 'should fulfill when the end_session_endpoint is provided with the issuer',
+          end_session_endpoint: 'http://localhost:8484/logout',
+        },
+        {
+          itMsg: 'should reject when the end_session_endpoint is not known',
+          errorMsg: /If that is expected, please set GRIST_OIDC_IDP_SKIP_END_SESSION_ENDPOINT/,
+          end_session_endpoint: undefined,
+        }
+      ].forEach((ctx) => {
+        it(ctx.itMsg, async () => {
+          setEnvVars();
+          Object.assign(process.env, ctx.env);
+          const client = new ClientStub();
+          client.issuer.metadata.end_session_endpoint = ctx.end_session_endpoint;
+          const promise = OIDCConfigStubbed.buildWithStub(client.asClient());
+          if (ctx.errorMsg) {
+            await assert.isRejected(promise, ctx.errorMsg);
+            assert.isFalse(isInitializedLogCalled());
+          } else {
+            await assert.isFulfilled(promise);
+            assert.isTrue(isInitializedLogCalled());
+          }
+        });
+      });
+    });
+  });
+
+  describe('GRIST_OIDC_IDP_ENABLED_PROTECTIONS', () => {
+    async function checkRejection(promise: Promise<OIDCConfig>, actualValue: string) {
+      return assert.isRejected(
+        promise,
+        `OIDC: Invalid protection in GRIST_OIDC_IDP_ENABLED_PROTECTIONS: "${actualValue}". ` +
+          'Expected at least one of these values: "STATE,NONCE,PKCE"');
+    }
+    it('should reject when GRIST_OIDC_IDP_ENABLED_PROTECTIONS contains unsupported values', async () => {
+      setEnvVars();
+      process.env.GRIST_OIDC_IDP_ENABLED_PROTECTIONS = 'STATE,NONCE,PKCE,invalid';
+      const promise = OIDCConfig.build(NOOPED_SEND_APP_PAGE);
+      await checkRejection(promise, 'invalid');
+    });
+
+    it('should successfully change the supported protections', async function () {
+      setEnvVars();
+      process.env.GRIST_OIDC_IDP_ENABLED_PROTECTIONS = 'NONCE';
+      const config = await OIDCConfigStubbed.buildWithStub();
+      assert.isTrue(config.supportsProtection("NONCE"));
+      assert.isFalse(config.supportsProtection("PKCE"));
+      assert.isFalse(config.supportsProtection("STATE"));
+    });
+
+    it('should reject when set to an empty string', async function () {
+      setEnvVars();
+      process.env.GRIST_OIDC_IDP_ENABLED_PROTECTIONS = '';
+      const promise = OIDCConfigStubbed.buildWithStub();
+      await checkRejection(promise, '');
+    });
+
+    it('should accept to be set to "UNPROTECTED"', async function () {
+      setEnvVars();
+      process.env.GRIST_OIDC_IDP_ENABLED_PROTECTIONS = 'UNPROTECTED';
+      const config = await OIDCConfigStubbed.buildWithStub();
+      assert.isFalse(config.supportsProtection("NONCE"));
+      assert.isFalse(config.supportsProtection("PKCE"));
+      assert.isFalse(config.supportsProtection("STATE"));
+      assert.equal(logWarnStub.callCount, 1, 'a warning should be raised');
+      assert.match(logWarnStub.firstCall.args[0], /with no protection/);
+    });
+
+    it('should reject when set to "UNPROTECTED,PKCE"', async function () {
+      setEnvVars();
+      process.env.GRIST_OIDC_IDP_ENABLED_PROTECTIONS = 'UNPROTECTED,PKCE';
+      const promise = OIDCConfigStubbed.buildWithStub();
+      await checkRejection(promise, 'UNPROTECTED');
+    });
+
+    it('if omitted, should default to "STATE,PKCE"', async function () {
+      setEnvVars();
+      const config = await OIDCConfigStubbed.buildWithStub();
+      assert.isFalse(config.supportsProtection("NONCE"));
+      assert.isTrue(config.supportsProtection("PKCE"));
+      assert.isTrue(config.supportsProtection("STATE"));
+    });
+  });
+
+  describe('getLoginRedirectUrl', () => {
+    const FAKE_NONCE = 'fake-nonce';
+    const FAKE_STATE = 'fake-state';
+    const FAKE_CODE_VERIFIER = 'fake-code-verifier';
+    const FAKE_CODE_CHALLENGE = 'fake-code-challenge';
+    const TARGET_URL = 'http://localhost:8484/';
+
+    beforeEach(() => {
+      sandbox.stub(generators, 'nonce').returns(FAKE_NONCE);
+      sandbox.stub(generators, 'state').returns(FAKE_STATE);
+      sandbox.stub(generators, 'codeVerifier').returns(FAKE_CODE_VERIFIER);
+      sandbox.stub(generators, 'codeChallenge').returns(FAKE_CODE_CHALLENGE);
+    });
+
+    [
+      {
+        itMsg: 'should forge the url with default values',
+        expectedCalledWith: [{
+          scope: 'openid email profile',
+          acr_values: undefined,
+          code_challenge: FAKE_CODE_CHALLENGE,
+          code_challenge_method: 'S256',
+          state: FAKE_STATE,
+        }],
+        expectedSession: {
+          oidc: {
+            code_verifier: FAKE_CODE_VERIFIER,
+            state: FAKE_STATE,
+            targetUrl: TARGET_URL,
+          }
+        }
+      },
+      {
+        itMsg: 'should forge the URL with passed GRIST_OIDC_IDP_SCOPES',
+        env: {
+          GRIST_OIDC_IDP_SCOPES: 'my scopes',
+        },
+        expectedCalledWith: [{
+          scope: 'my scopes',
+          acr_values: undefined,
+          code_challenge: FAKE_CODE_CHALLENGE,
+          code_challenge_method: 'S256',
+          state: FAKE_STATE,
+        }],
+        expectedSession: {
+          oidc: {
+            code_verifier: FAKE_CODE_VERIFIER,
+            state: FAKE_STATE,
+            targetUrl: TARGET_URL,
+          }
+        }
+      },
+      {
+        itMsg: 'should pass the nonce when GRIST_OIDC_IDP_ENABLED_PROTECTIONS includes NONCE',
+        env: {
+          GRIST_OIDC_IDP_ENABLED_PROTECTIONS: 'STATE,NONCE,PKCE',
+        },
+        expectedCalledWith: [{
+          scope: 'openid email profile',
+          acr_values: undefined,
+          code_challenge: FAKE_CODE_CHALLENGE,
+          code_challenge_method: 'S256',
+          state: FAKE_STATE,
+          nonce: FAKE_NONCE,
+        }],
+        expectedSession: {
+          oidc: {
+            code_verifier: FAKE_CODE_VERIFIER,
+            nonce: FAKE_NONCE,
+            state: FAKE_STATE,
+            targetUrl: TARGET_URL,
+          }
+        }
+      },
+      {
+        itMsg: 'should not pass the code_challenge when PKCE is omitted in GRIST_OIDC_IDP_ENABLED_PROTECTIONS',
+        env: {
+          GRIST_OIDC_IDP_ENABLED_PROTECTIONS: 'STATE,NONCE',
+        },
+        expectedCalledWith: [{
+          scope: 'openid email profile',
+          acr_values: undefined,
+          state: FAKE_STATE,
+          nonce: FAKE_NONCE,
+        }],
+        expectedSession: {
+          oidc: {
+            nonce: FAKE_NONCE,
+            state: FAKE_STATE,
+            targetUrl: TARGET_URL,
+          }
+        }
+      },
+    ].forEach(ctx => {
+      it(ctx.itMsg, async () => {
+        setEnvVars();
+        Object.assign(process.env, ctx.env);
+        const clientStub = new ClientStub();
+        const config = await OIDCConfigStubbed.buildWithStub(clientStub.asClient());
+        const session = {};
+        const req = {
+          session
+        } as unknown as express.Request;
+        const url = await config.getLoginRedirectUrl(req, new URL(TARGET_URL));
+        assert.equal(url, ClientStub.FAKE_REDIRECT_URL);
+        assert.isTrue(clientStub.authorizationUrl.calledOnce);
+        assert.deepEqual(clientStub.authorizationUrl.firstCall.args, ctx.expectedCalledWith);
+        assert.deepEqual(session, ctx.expectedSession);
+      });
+    });
+  });
+
+  describe('handleCallback', () => {
+    const FAKE_STATE = 'fake-state';
+    const FAKE_NONCE = 'fake-nonce';
+    const FAKE_CODE_VERIFIER = 'fake-code-verifier';
+    const FAKE_USER_INFO = {
+      email: 'fake-email',
+      name: 'fake-name',
+      email_verified: true,
+    };
+    const DEFAULT_SESSION = {
+      oidc: {
+        code_verifier: FAKE_CODE_VERIFIER,
+        state: FAKE_STATE
+      }
+    } as SessionObj;
+    const DEFAULT_EXPECTED_CALLBACK_CHECKS = {
+      state: FAKE_STATE,
+      code_verifier: FAKE_CODE_VERIFIER,
+    };
+    let fakeRes: {
+      status: Sinon.SinonStub;
+      send: Sinon.SinonStub;
+      redirect: Sinon.SinonStub;
+    };
+    let fakeSessions: {
+      getOrCreateSessionFromRequest: Sinon.SinonStub
+    };
+    let fakeScopedSession: {
+      operateOnScopedSession: Sinon.SinonStub
+    };
+
+    beforeEach(() => {
+      fakeRes = {
+        redirect: Sinon.stub(),
+        status: Sinon.stub().returnsThis(),
+        send: Sinon.stub().returnsThis(),
+      };
+      fakeScopedSession = {
+        operateOnScopedSession: Sinon.stub().resolves(),
+      };
+      fakeSessions = {
+        getOrCreateSessionFromRequest: Sinon.stub().returns(fakeScopedSession),
+      };
+    });
+
+    function checkUserProfile(expectedUserProfile: object) {
+      return function ({user}: {user: any}) {
+        assert.deepEqual(user.profile, expectedUserProfile,
+          `user profile should have been populated with ${JSON.stringify(expectedUserProfile)}`);
+      };
+    }
+
+    function checkRedirect(expectedRedirection: string) {
+      return function ({fakeRes}: {fakeRes: any}) {
+        assert.deepEqual(fakeRes.redirect.firstCall.args, [expectedRedirection],
+          `should have redirected to ${expectedRedirection}`);
+      };
+    }
+
+    [
+      {
+        itMsg: 'should reject when no OIDC information is present in the session',
+        session: {},
+        expectedErrorMsg: /Missing OIDC information/
+      },
+      {
+        itMsg: 'should resolve when the state and the code challenge are found in the session',
+        session: DEFAULT_SESSION,
+      },
+      {
+        itMsg: 'should reject when the state is not found in the session',
+        session: {
+          oidc: {}
+        },
+        expectedErrorMsg: /Login or logout failed to complete/,
+      },
+      {
+        itMsg: 'should resolve when the state is missing and its check has been disabled (UNPROTECTED)',
+        session: DEFAULT_SESSION,
+        env: {
+          GRIST_OIDC_IDP_ENABLED_PROTECTIONS: 'UNPROTECTED',
+        },
+        expectedCbChecks: {},
+      },
+      {
+        itMsg: 'should reject when the code_verifier is missing from the session',
+        session: {
+          oidc: {
+            state: FAKE_STATE,
+            GRIST_OIDC_IDP_ENABLED_PROTECTIONS: 'STATE,PKCE'
+          }
+        },
+        expectedErrorMsg: /Login is stale/,
+      },
+      {
+        itMsg: 'should resolve when the code_verifier is missing and its check has been disabled',
+        session: {
+          oidc: {
+            state: FAKE_STATE,
+            nonce: FAKE_NONCE,
+          }
+        },
+        env: {
+          GRIST_OIDC_IDP_ENABLED_PROTECTIONS: 'STATE,NONCE',
+        },
+        expectedCbChecks: {
+          state: FAKE_STATE,
+          nonce: FAKE_NONCE,
+        },
+      },
+      {
+        itMsg: 'should reject when nonce is missing from the session despite its check being enabled',
+        session: DEFAULT_SESSION,
+        env: {
+          GRIST_OIDC_IDP_ENABLED_PROTECTIONS: 'STATE,NONCE,PKCE',
+        },
+        expectedErrorMsg: /Login is stale/,
+      }, {
+        itMsg: 'should resolve when nonce is present in the session and its check is enabled',
+        session: {
+          oidc: {
+            state: FAKE_STATE,
+            nonce: FAKE_NONCE,
+            code_verifier: undefined,
+          },
+        },
+        env: {
+          GRIST_OIDC_IDP_ENABLED_PROTECTIONS: 'STATE,NONCE',
+        },
+        expectedCbChecks: {
+          state: FAKE_STATE,
+          nonce: FAKE_NONCE,
+        },
+      },
+      {
+        itMsg: 'should reject when the userinfo mail is not verified',
+        session: DEFAULT_SESSION,
+        userInfo: {
+          ...FAKE_USER_INFO,
+          email_verified: false,
+        },
+        expectedErrorMsg: /email not verified for/,
+        extraChecks: function ({ sendAppPageStub }: { sendAppPageStub: Sinon.SinonStub }) {
+          assert.equal(sendAppPageStub.firstCall.args[2].config.errMessage, 'oidc.emailNotVerifiedError');
+        }
+      },
+      {
+        itMsg: 'should resolve when the userinfo mail is not verified but its check disabled',
+        session: DEFAULT_SESSION,
+        userInfo: {
+          ...FAKE_USER_INFO,
+          email_verified: false,
+        },
+        env: {
+          GRIST_OIDC_SP_IGNORE_EMAIL_VERIFIED: 'true',
+        }
+      },
+      {
+        itMsg: 'should resolve when the userinfo mail is not verified but its check disabled',
+        session: DEFAULT_SESSION,
+        userInfo: {
+          ...FAKE_USER_INFO,
+          email_verified: false,
+        },
+        env: {
+          GRIST_OIDC_SP_IGNORE_EMAIL_VERIFIED: 'true',
+        },
+      },
+      {
+        itMsg: 'should fill user profile with email and name',
+        session: DEFAULT_SESSION,
+        userInfo: FAKE_USER_INFO,
+        extraChecks: checkUserProfile({
+          email: FAKE_USER_INFO.email,
+          name: FAKE_USER_INFO.name,
+        })
+      },
+      {
+        itMsg: 'should fill user profile with name constructed using ' +
+          'given_name and family_name when GRIST_OIDC_SP_PROFILE_NAME_ATTR is not set',
+        session: DEFAULT_SESSION,
+        userInfo: {
+          ...FAKE_USER_INFO,
+          given_name: 'given_name',
+          family_name: 'family_name',
+        },
+        extrachecks: checkUserProfile({
+          email: 'fake-email',
+          name: 'given_name family_name',
+        })
+      },
+      {
+        itMsg: 'should fill user profile with email and name when ' +
+          'GRIST_OIDC_SP_PROFILE_NAME_ATTR and GRIST_OIDC_SP_PROFILE_EMAIL_ATTR are set',
+        session: DEFAULT_SESSION,
+        userInfo: {
+          ...FAKE_USER_INFO,
+          fooMail: 'fake-email2',
+          fooName: 'fake-name2',
+        },
+        env: {
+          GRIST_OIDC_SP_PROFILE_NAME_ATTR: 'fooName',
+          GRIST_OIDC_SP_PROFILE_EMAIL_ATTR: 'fooMail',
+        },
+        extraChecks: checkUserProfile({
+          email: 'fake-email2',
+          name: 'fake-name2',
+        }),
+      },
+      {
+        itMsg: 'should redirect by default to the root page',
+        session: DEFAULT_SESSION,
+        extraChecks: checkRedirect('/'),
+      },
+      {
+        itMsg: 'should redirect to the targetUrl when it is present in the session',
+        session: {
+          oidc: {
+            ...DEFAULT_SESSION.oidc,
+            targetUrl: 'http://localhost:8484/some/path'
+          }
+        },
+        extraChecks: checkRedirect('http://localhost:8484/some/path'),
+      },
+      {
+        itMsg: "should redact confidential information in the tokenSet in the logs",
+        session: DEFAULT_SESSION,
+        tokenSet: {
+          id_token: 'fake-id-token',
+          access_token: 'fake-access',
+          whatever: 'fake-whatever',
+          token_type: 'fake-token-type',
+          expires_at: 1234567890,
+          expires_in: 987654321,
+          scope: 'fake-scope',
+        },
+        extraChecks: function () {
+          assert.isTrue(logDebugStub.called);
+          assert.deepEqual(logDebugStub.firstCall.args, [
+            'Got tokenSet: %o', {
+              id_token: 'REDACTED',
+              access_token: 'REDACTED',
+              whatever: 'REDACTED',
+              token_type: this.tokenSet.token_type,
+              expires_at: this.tokenSet.expires_at,
+              expires_in: this.tokenSet.expires_in,
+              scope: this.tokenSet.scope,
+            }
+          ]);
+        }
+      },
+    ].forEach(ctx => {
+      it(ctx.itMsg, async () => {
+        setEnvVars();
+        Object.assign(process.env, ctx.env);
+        const clientStub = new ClientStub();
+        const sendAppPageStub = Sinon.stub().resolves();
+        const fakeParams = {
+          state: FAKE_STATE,
+        };
+        const config = await OIDCConfigStubbed.build(sendAppPageStub as SendAppPageFunction, clientStub.asClient());
+        const session = _.clone(ctx.session); // session is modified, so clone it
+        const req = {
+          session,
+          t: (key: string) => key
+        } as unknown as express.Request;
+        clientStub.callbackParams.returns(fakeParams);
+        const tokenSet = { id_token: 'id_token', ...ctx.tokenSet };
+        clientStub.callback.resolves(tokenSet);
+        clientStub.userinfo.returns(_.clone(ctx.userInfo ?? FAKE_USER_INFO));
+        const user: { profile?: object } = {};
+        fakeScopedSession.operateOnScopedSession.yields(user);
+
+        await config.handleCallback(
+          fakeSessions as unknown as Sessions,
+          req,
+          fakeRes as unknown as express.Response
+        );
+
+        if (ctx.expectedErrorMsg) {
+          assert.isTrue(logErrorStub.calledOnce);
+          assert.match(logErrorStub.firstCall.args[0], ctx.expectedErrorMsg);
+          assert.isTrue(sendAppPageStub.calledOnceWith(req, fakeRes));
+          assert.include(sendAppPageStub.firstCall.args[2], {
+            path: 'error.html',
+            status: 500,
+          });
+        } else {
+          assert.isFalse(logErrorStub.called, 'no error should be logged. Got: ' + logErrorStub.firstCall?.args[0]);
+          assert.isTrue(fakeRes.redirect.calledOnce, 'should redirect');
+          assert.isTrue(clientStub.callback.calledOnce);
+          assert.deepEqual(clientStub.callback.firstCall.args, [
+            'http://localhost:8484/oauth2/callback',
+            fakeParams,
+            ctx.expectedCbChecks ?? DEFAULT_EXPECTED_CALLBACK_CHECKS
+          ]);
+          assert.deepEqual(session, {
+            oidc: {
+              idToken: tokenSet.id_token,
+            }
+          }, 'oidc info should only keep state and id_token in the session and for the logout');
+        }
+        ctx.extraChecks?.({ fakeRes, user, sendAppPageStub });
+      });
+    });
+
+    it('should log err.response when userinfo fails to parse response body', async () => {
+      // See https://github.com/panva/node-openid-client/blob/47a549cb4e36ffe2ebfe2dc9d6b69a02643cc0a9/lib/client.js#L1293
+      setEnvVars();
+      const clientStub = new ClientStub();
+      const sendAppPageStub = Sinon.stub().resolves();
+      const config = await OIDCConfigStubbed.build(sendAppPageStub, clientStub.asClient());
+      const req = {
+        session: DEFAULT_SESSION,
+      } as unknown as express.Request;
+      clientStub.callbackParams.returns({state: FAKE_STATE});
+      const errorResponse = {
+        body: { property: 'response here' },
+        statusCode: 400,
+        statusMessage: 'statusMessage'
+      } as unknown as any;
+
+      const err = new OIDCError.OPError({error: 'userinfo failed'}, errorResponse);
+      clientStub.userinfo.rejects(err);
+
+      await config.handleCallback(
+        fakeSessions as unknown as Sessions,
+        req,
+        fakeRes as unknown as express.Response
+      );
+
+      assert.equal(logErrorStub.callCount, 2, 'logErrorStub show be called twice');
+      assert.include(logErrorStub.firstCall.args[0], err.message);
+      assert.include(logErrorStub.secondCall.args[0], 'Response received');
+      assert.deepEqual(logErrorStub.secondCall.args[1], errorResponse);
+      assert.isTrue(sendAppPageStub.calledOnce, "An error should have been sent");
+    });
+  });
+
+  describe('getLogoutRedirectUrl', () => {
+    const REDIRECT_URL = new URL('http://localhost:8484/docs/signed-out');
+    const URL_RETURNED_BY_CLIENT = 'http://localhost:8484/logout_url_from_issuer';
+    const ENV_VALUE_GRIST_OIDC_IDP_END_SESSION_ENDPOINT = 'http://localhost:8484/logout';
+    const FAKE_SESSION = {
+      oidc: {
+        idToken: 'id_token',
+      }
+    } as SessionObj;
+
+    [
+      {
+        itMsg: 'should skip the end session endpoint when GRIST_OIDC_IDP_SKIP_END_SESSION_ENDPOINT=true',
+        env: {
+          GRIST_OIDC_IDP_SKIP_END_SESSION_ENDPOINT: 'true',
+        },
+        expectedUrl: REDIRECT_URL.href,
+      }, {
+        itMsg: 'should use the GRIST_OIDC_IDP_END_SESSION_ENDPOINT when it is set',
+        env: {
+          GRIST_OIDC_IDP_END_SESSION_ENDPOINT: ENV_VALUE_GRIST_OIDC_IDP_END_SESSION_ENDPOINT
+        },
+        expectedUrl: ENV_VALUE_GRIST_OIDC_IDP_END_SESSION_ENDPOINT
+      }, {
+        itMsg: 'should call the end session endpoint with the expected parameters',
+        expectedUrl: URL_RETURNED_BY_CLIENT,
+        expectedLogoutParams: {
+          post_logout_redirect_uri: REDIRECT_URL.href,
+          id_token_hint: FAKE_SESSION.oidc!.idToken,
+        }
+      }, {
+        itMsg: 'should call the end session endpoint with no idToken if session is missing',
+        expectedUrl: URL_RETURNED_BY_CLIENT,
+        expectedLogoutParams: {
+          post_logout_redirect_uri: REDIRECT_URL.href,
+          id_token_hint: undefined,
+        },
+        session: null
+      }
+    ].forEach(ctx => {
+      it(ctx.itMsg, async () => {
+        setEnvVars();
+        Object.assign(process.env, ctx.env);
+        const clientStub = new ClientStub();
+        clientStub.endSessionUrl.returns(URL_RETURNED_BY_CLIENT);
+        const config = await OIDCConfigStubbed.buildWithStub(clientStub.asClient());
+        const req = {
+          session: 'session' in ctx ? ctx.session : FAKE_SESSION
+        } as unknown as RequestWithLogin;
+        const url = await config.getLogoutRedirectUrl(req, REDIRECT_URL);
+        assert.equal(url, ctx.expectedUrl);
+        if (ctx.expectedLogoutParams) {
+          assert.isTrue(clientStub.endSessionUrl.calledOnce);
+          assert.deepEqual(clientStub.endSessionUrl.firstCall.args, [ctx.expectedLogoutParams]);
+        }
+      });
+    });
+  });
+});

From 4ed90faf792bbf04360d2048a0c98f59abb187f3 Mon Sep 17 00:00:00 2001
From: Dmitry S <dsagal+git@gmail.com>
Date: Fri, 9 Aug 2024 14:20:08 -0400
Subject: [PATCH 121/145] (core) Fix more tests: bundleSize and Embed

Summary:
1. Unclear why Embed fails often. Locally, it fails for me every time, and
   this tweak makes it pass (while still keeping the test useful).
2. Reduced back main bundle's size by removing dependency of some common
   elements on the full AdminPanel. Updated expected size of errorPages
   bundle to the new reduced size.

Test Plan: No changes to functionality; relying on existing tests to verify that.

Reviewers: jordigh

Reviewed By: jordigh

Subscribers: georgegevoian, jordigh

Differential Revision: https://phab.getgrist.com/D4315
---
 app/client/ui/AccountWidget.ts  |  2 +-
 app/client/ui/AdminPanel.ts     |  6 +-----
 app/client/ui/AdminPanelName.ts | 11 +++++++++++
 app/client/ui/HomeLeftPane.ts   |  2 +-
 4 files changed, 14 insertions(+), 7 deletions(-)
 create mode 100644 app/client/ui/AdminPanelName.ts

diff --git a/app/client/ui/AccountWidget.ts b/app/client/ui/AccountWidget.ts
index 10723e1a..3a968a56 100644
--- a/app/client/ui/AccountWidget.ts
+++ b/app/client/ui/AccountWidget.ts
@@ -1,7 +1,7 @@
 import {AppModel} from 'app/client/models/AppModel';
 import {DocPageModel} from 'app/client/models/DocPageModel';
 import {getLoginOrSignupUrl, getLoginUrl, getLogoutUrl, getSignupUrl, urlState} from 'app/client/models/gristUrlState';
-import {getAdminPanelName} from 'app/client/ui/AdminPanel';
+import {getAdminPanelName} from 'app/client/ui/AdminPanelName';
 import {manageTeamUsers} from 'app/client/ui/OpenUserManager';
 import {createUserImage} from 'app/client/ui/UserImage';
 import * as viewport from 'app/client/ui/viewport';
diff --git a/app/client/ui/AdminPanel.ts b/app/client/ui/AdminPanel.ts
index c0bb0682..ae1fa8ef 100644
--- a/app/client/ui/AdminPanel.ts
+++ b/app/client/ui/AdminPanel.ts
@@ -25,14 +25,10 @@ import * as version from 'app/common/version';
 import {Computed, Disposable, dom, IDisposable,
         IDisposableOwner, MultiHolder, Observable, styled} from 'grainjs';
 import {AdminSection, AdminSectionItem, HidableToggle} from 'app/client/ui/AdminPanelCss';
+import {getAdminPanelName} from 'app/client/ui/AdminPanelName';
 
 const t = makeT('AdminPanel');
 
-// Translated "Admin Panel" name, made available to other modules.
-export function getAdminPanelName() {
-  return t("Admin Panel");
-}
-
 export class AdminPanel extends Disposable {
   private _supportGrist = SupportGristPage.create(this, this._appModel);
   private _toggleEnterprise = ToggleEnterpriseWidget.create(this);
diff --git a/app/client/ui/AdminPanelName.ts b/app/client/ui/AdminPanelName.ts
new file mode 100644
index 00000000..cd946d21
--- /dev/null
+++ b/app/client/ui/AdminPanelName.ts
@@ -0,0 +1,11 @@
+// Separated out into its own file because this is used in several modules, but we'd like to avoid
+// pulling in the full AdminPanel into their bundle.
+
+import {makeT} from 'app/client/lib/localization';
+
+const t = makeT('AdminPanel');
+
+// Translated "Admin Panel" name, made available to other modules.
+export function getAdminPanelName() {
+  return t("Admin Panel");
+}
diff --git a/app/client/ui/HomeLeftPane.ts b/app/client/ui/HomeLeftPane.ts
index 895933f7..1489b6ab 100644
--- a/app/client/ui/HomeLeftPane.ts
+++ b/app/client/ui/HomeLeftPane.ts
@@ -5,7 +5,7 @@ import {reportError} from 'app/client/models/AppModel';
 import {docUrl, urlState} from 'app/client/models/gristUrlState';
 import {HomeModel} from 'app/client/models/HomeModel';
 import {getWorkspaceInfo, workspaceName} from 'app/client/models/WorkspaceInfo';
-import {getAdminPanelName} from 'app/client/ui/AdminPanel';
+import {getAdminPanelName} from 'app/client/ui/AdminPanelName';
 import {addNewButton, cssAddNewButton} from 'app/client/ui/AddNewButton';
 import {docImport, importFromPlugin} from 'app/client/ui/HomeImports';
 import {

From 69aabd1ae09293906ca49f9a92f9bede4dbdd1f6 Mon Sep 17 00:00:00 2001
From: Dmitry S <dsagal+git@gmail.com>
Date: Thu, 8 Aug 2024 01:04:44 -0400
Subject: [PATCH 122/145] (core) Limit related videos when playing onboarding
 video tour from home page

Summary:
When video is opened from the app homepage, it opens in a popup, which stays
open when it ends. The rel=0 parameter limits the related videos shown at the
end to those from the same channel, avoiding surprising unrelated videos.

This doesn't affect the video shown during initial onboarding, since that once
auto-closes when it ends.

Test Plan: Tested manually

Reviewers: georgegevoian

Reviewed By: georgegevoian

Differential Revision: https://phab.getgrist.com/D4313
---
 app/client/ui/OpenVideoTour.ts | 3 +++
 app/client/ui/YouTubePlayer.ts | 1 +
 2 files changed, 4 insertions(+)

diff --git a/app/client/ui/OpenVideoTour.ts b/app/client/ui/OpenVideoTour.ts
index 8bd230d3..1a0cc5db 100644
--- a/app/client/ui/OpenVideoTour.ts
+++ b/app/client/ui/OpenVideoTour.ts
@@ -26,6 +26,9 @@ const testId = makeTestId('test-video-tour-');
           height: '100%',
           width: '100%',
           origin: getMainOrgUrl(),
+          playerVars: {
+            rel: 0,
+          },
         },
         cssYouTubePlayer.cls(''),
       );
diff --git a/app/client/ui/YouTubePlayer.ts b/app/client/ui/YouTubePlayer.ts
index d7db5649..384f7044 100644
--- a/app/client/ui/YouTubePlayer.ts
+++ b/app/client/ui/YouTubePlayer.ts
@@ -29,6 +29,7 @@ export interface PlayerVars {
   fs?: 0 | 1;
   iv_load_policy?: 1 | 3;
   modestbranding?: 0 | 1;
+  rel?: 0 | 1;
 }
 
 export interface PlayerStateChangeEvent {

From 6bbcf9c1d7e97d7e6ff9b0d957237c150224049c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= <jordigh@octave.org>
Date: Fri, 9 Aug 2024 18:03:56 -0400
Subject: [PATCH 123/145] v1.1.17

---
 buildtools/.grist-ee-version | 2 +-
 package.json                 | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/buildtools/.grist-ee-version b/buildtools/.grist-ee-version
index 85b7c695..c81aa44a 100644
--- a/buildtools/.grist-ee-version
+++ b/buildtools/.grist-ee-version
@@ -1 +1 @@
-0.9.6
+0.9.7
diff --git a/package.json b/package.json
index 708933bb..78916f46 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "grist-core",
-  "version": "1.1.16",
+  "version": "1.1.17",
   "license": "Apache-2.0",
   "description": "Grist is the evolution of spreadsheets",
   "homepage": "https://github.com/gristlabs/grist-core",

From 95320f3f5bea3bf2b5aeaa9be579c264a5ffb860 Mon Sep 17 00:00:00 2001
From: Dmitry S <dsagal+git@gmail.com>
Date: Wed, 31 Jul 2024 09:52:02 -0700
Subject: [PATCH 124/145] (core) Update documentation for lookup/find/prevnext
 for the Help Center

Summary: Documentation in https://github.com/gristlabs/grist-help/ is built using `./build-functions.sh`, and the current version (as updated and landed in https://github.com/gristlabs/grist-help/pull/368) already reflects the changes in this diff.

Test Plan: No changes to functionality, only documentation comments.

Reviewers: jarek

Reviewed By: jarek

Differential Revision: https://phab.getgrist.com/D4306
---
 sandbox/grist/functions/prevnext.py | 35 ++++++++++++++++++-----------
 sandbox/grist/records.py            | 35 ++++++++++++++++-------------
 sandbox/grist/table.py              |  9 ++++----
 3 files changed, 47 insertions(+), 32 deletions(-)

diff --git a/sandbox/grist/functions/prevnext.py b/sandbox/grist/functions/prevnext.py
index eb6bb123..53d57602 100644
--- a/sandbox/grist/functions/prevnext.py
+++ b/sandbox/grist/functions/prevnext.py
@@ -5,24 +5,33 @@ def PREVIOUS(rec, *, group_by=(), order_by):
   column IDs, and `order_by` allows column IDs to be prefixed with "-" to reverse sort order.
 
   For example,
-  - `PREVIOUS(rec, order_by="Date")` will return the previous record when the list of records is
-    sorted by the Date column.
-  - `PREVIOUS(rec, order_by="-Date")` will return the previous record when the list is sorted by
-    the Date column in descending order.
-  - `PREVIOUS(rec, group_by="Account", order_by="Date")` will return the previous record with the
-    same Account as `rec`, when records are filtered by the Account of `rec` and sorted by Date.
+  ```python
+  PREVIOUS(rec, order_by="Date")    # The previous record when sorted by increasing Date.
+  PREVIOUS(rec, order_by="-Date")   # The previous record when sorted by decreasing Date.
+  ```
+
+  You may use `group_by` to search for the previous record within a filtered group. For example,
+  this finds the previous record with the same Account as `rec`, when records are filtered by the
+  Account of `rec` and sorted by increasing Date:
+  ```python
+  PREVIOUS(rec, group_by="Account", order_by="Date")
+  ```
 
   When multiple records have the same `order_by` values (e.g. the same Date in the examples above),
   the order is determined by the relative position of rows in views. This is done internally by
   falling back to the special column `manualSort` and the row ID column `id`.
 
   Use `order_by=None` to find the previous record in an unsorted table (when rows may be
-  rearranged by dragging them manually). For example,
-  - `PREVIOUS(rec, order_by=None)` will return the previous record in the unsorted list of records.
+  rearranged by dragging them manually). For example:
+  ```python
+  PREVIOUS(rec, order_by=None)      # The previous record in the unsorted list of records.
+  ```
 
   You may specify multiple column IDs as a tuple, for both `group_by` and `order_by`. This can be
   used to match views sorted by multiple columns. For example:
-  - `PREVIOUS(rec, group_by=("Account", "Year"), order_by=("Date", "-Amount"))`
+  ```python
+  PREVIOUS(rec, group_by=("Account", "Year"), order_by=("Date", "-Amount"))
+  ```
   """
   return _sorted_lookup(rec, group_by=group_by, order_by=order_by)._find.previous(rec)
 
@@ -39,10 +48,10 @@ def RANK(rec, *, group_by=(), order_by, order="asc"):
   `order_by`, and grouping specified by `group_by`. See [`PREVIOUS`](#previous) for details of
   these parameters.
 
-  The `order` parameter may be "asc" (which is the default) or "desc".
+  The `order` parameter may be `"asc"` (which is the default) or `"desc"`.
 
-  When `order` is "asc" or omitted, the first record in the group in the sorted order would have
-  the rank of 1. When `order` is "desc", the last record in the sorted order would have the rank
+  When `order` is `"asc"` or omitted, the first record in the group in the sorted order would have
+  the rank of 1. When `order` is `"desc"`, the last record in the sorted order would have the rank
   of 1.
 
   If there are multiple groups, there will be multiple records with the same rank. In particular,
@@ -50,7 +59,7 @@ def RANK(rec, *, group_by=(), order_by, order="asc"):
 
   For example, `RANK(rec, group_by="Year", order_by="Score", order="desc")` will return the rank of
   the current record (`rec`) among all the records in its table for the same year, ordered by
-  score.
+  decreasing score.
   """
   return _sorted_lookup(rec, group_by=group_by, order_by=order_by)._find.rank(rec, order=order)
 
diff --git a/sandbox/grist/records.py b/sandbox/grist/records.py
index 4a67ce23..1ff7c6e7 100644
--- a/sandbox/grist/records.py
+++ b/sandbox/grist/records.py
@@ -243,26 +243,30 @@ class RecordSet(object):
   @property
   def find(self):
     """
-    A set of methods for finding values in sorted set of records. For example:
+    Name: find.*
+    Usage: RecordSet.**find.\\***(value)
+
+    A set of methods for finding values in sorted sets of records, as returned by
+    [`lookupRecords`](#lookuprecords). For example:
     ```
-    Transactions.lookupRecords(..., sort_by="Date").find.lt($Date)
-    Table.lookupRecords(..., sort_by=("Foo", "Bar")).find.le(foo, bar)
+    Transactions.lookupRecords(..., order_by="Date").find.lt($Date)
+    Table.lookupRecords(..., order_by=("Foo", "Bar")).find.le(foo, bar)
     ```
 
-    If the `find` method is shadowed by a same-named user column, you may use `_find` instead.
+    If the `find` attribute is shadowed by a same-named user column, you may use `_find` instead.
 
     The methods available are:
 
-    - `lt`: (less than) find nearest record with sort values < the given values
-    - `le`: (less than or equal to) find nearest record with sort values <= the given values
-    - `gt`: (greater than) find nearest record with sort values > the given values
-    - `ge`: (greater than or equal to) find nearest record with sort values >= the given values
-    - `eq`: (equal to) find nearest record with sort values == the given values
+    - __`lt`__: (less than) find nearest record with sort values < the given values
+    - __`le`__: (less than or equal to) find nearest record with sort values <= the given values
+    - __`gt`__: (greater than) find nearest record with sort values > the given values
+    - __`ge`__: (greater than or equal to) find nearest record with sort values >= the given values
+    - __`eq`__: (equal to) find nearest record with sort values == the given values
 
-    Example from https://templates.getgrist.com/5pHLanQNThxk/Payroll. Each person has a history of
-    pay rates, in the Rates table. To find a rate applicable on a certain date, here is how you
-    can do it old-style:
-    ```
+    Example from [our Payroll template](https://templates.getgrist.com/5pHLanQNThxk/Payroll).
+    Each person has a history of pay rates, in the Rates table. To find a rate applicable on a
+    certain date, here is how you can do it old-style:
+    ```python
     # Get all the rates for the Person and Role in this row.
     rates = Rates.lookupRecords(Person=$Person, Role=$Role)
 
@@ -277,8 +281,9 @@ class RecordSet(object):
     ```
 
     With the new methods, it is much simpler:
-    ```
-    rate = Rates.lookupRecords(Person=$Person, Role=$Role, sort_by="Rate_Start").find.le($Date)
+    ```python
+    rates = Rates.lookupRecords(Person=$Person, Role=$Role, order_by="Rate_Start")
+    rate = rates.find.le($Date)
     return rate.Hourly_Rate
     ```
 
diff --git a/sandbox/grist/table.py b/sandbox/grist/table.py
index 94eabeaa..f04f05de 100644
--- a/sandbox/grist/table.py
+++ b/sandbox/grist/table.py
@@ -95,7 +95,8 @@ class UserTable(object):
     For backward compatibility, `sort_by` may be used instead of `order_by`, but only allows a
     single field, and falls back to row ID (rather than `manualSort`).
 
-    See [RecordSet](#recordset) for useful properties offered by the returned object.
+    See [RecordSet](#recordset) for useful properties offered by the returned object. In
+    particular, methods like [`.find.le`](#find_) allow searching for nearest values.
 
     See [CONTAINS](#contains) for an example utilizing `UserTable.lookupRecords` to find records
     where a field of a list type (such as `Choice List` or `Reference List`) contains the given
@@ -127,13 +128,13 @@ class UserTable(object):
     parameter to the column ID by which to sort the matches, to determine which of them is
     returned as the first one. By default, the record with the lowest row ID is returned.
 
-    See [`lookupRecords`](#lookupRecords) for details of all available options and behavior of
+    See [`lookupRecords`](#lookuprecords) for details of all available options and behavior of
     `order_by` (and of its legacy alternative, `sort_by`).
 
     For example:
     ```
-    Tasks.lookupOne(Project=$id, order_by="Priority")  # Returns the Task with the smallest Priority.
-    Rates.lookupOne(Person=$id, order_by="-Date")      # Returns the Rate with the latest Date.
+    Tasks.lookupOne(Project=$id, order_by="Priority")  # Task with the smallest Priority.
+    Rates.lookupOne(Person=$id, order_by="-Date")      # Rate with the latest Date.
     ```
     """
     return self.table.lookup_one_record(**field_value_pairs)

From dfb816888edf489c8ad51b5114dc0248c948991f Mon Sep 17 00:00:00 2001
From: Spoffy <4805393+Spoffy@users.noreply.github.com>
Date: Mon, 12 Aug 2024 20:54:43 +0100
Subject: [PATCH 125/145] Adds docker compose examples (#1113)

This adds three example docker-compose files:

- A basic Grist instance backed by sqlite, with no additional services.
- A Grist instance that uses Postgres, Redis and MinIO.
- A Grist instance that uses OIDC authentication and traefik.

These are intended to be customised by self-hosters for their own needs.

All examples should work without any additional configuration.
---
 .gitignore                                    |    5 +
 .../grist-local-testing/README.md             |   12 +
 .../grist-local-testing/docker-compose.yml    |    8 +
 .../grist-traefik-basic-auth/README.md        |   24 +
 .../configs/traefik-config.yml                |   35 +
 .../configs/traefik-dynamic-config.yml        |   13 +
 .../docker-compose.yml                        |   44 +
 .../grist-traefik-oidc-auth/README.md         |   32 +
 .../configs/authelia/configuration.yml        | 1423 +++++++++++++++++
 .../configs/authelia/users_database.yml       |   14 +
 .../configs/traefik/config.yml                |   30 +
 .../docker-compose.yml                        |  118 ++
 .../grist-traefik-oidc-auth/env-template      |    6 +
 .../generateSecureSecrets.sh                  |   32 +
 .../GRIST_CLIENT_SECRET_DIGEST                |    0
 .../secrets_template/HMAC_SECRET              |    0
 .../secrets_template/JWT_SECRET               |    0
 .../secrets_template/SESSION_SECRET           |    0
 .../secrets_template/STORAGE_ENCRYPTION_KEY   |    0
 .../secrets_template/certs/private.pem        |    0
 .../grist-with-postgres-redis-minio/.env      |    3 +
 .../grist-with-postgres-redis-minio/README.md |   20 +
 .../docker-compose.yml                        |   76 +
 23 files changed, 1895 insertions(+)
 create mode 100644 docker-compose-examples/grist-local-testing/README.md
 create mode 100644 docker-compose-examples/grist-local-testing/docker-compose.yml
 create mode 100644 docker-compose-examples/grist-traefik-basic-auth/README.md
 create mode 100644 docker-compose-examples/grist-traefik-basic-auth/configs/traefik-config.yml
 create mode 100644 docker-compose-examples/grist-traefik-basic-auth/configs/traefik-dynamic-config.yml
 create mode 100644 docker-compose-examples/grist-traefik-basic-auth/docker-compose.yml
 create mode 100644 docker-compose-examples/grist-traefik-oidc-auth/README.md
 create mode 100644 docker-compose-examples/grist-traefik-oidc-auth/configs/authelia/configuration.yml
 create mode 100644 docker-compose-examples/grist-traefik-oidc-auth/configs/authelia/users_database.yml
 create mode 100644 docker-compose-examples/grist-traefik-oidc-auth/configs/traefik/config.yml
 create mode 100644 docker-compose-examples/grist-traefik-oidc-auth/docker-compose.yml
 create mode 100644 docker-compose-examples/grist-traefik-oidc-auth/env-template
 create mode 100755 docker-compose-examples/grist-traefik-oidc-auth/generateSecureSecrets.sh
 create mode 100644 docker-compose-examples/grist-traefik-oidc-auth/secrets_template/GRIST_CLIENT_SECRET_DIGEST
 create mode 100644 docker-compose-examples/grist-traefik-oidc-auth/secrets_template/HMAC_SECRET
 create mode 100644 docker-compose-examples/grist-traefik-oidc-auth/secrets_template/JWT_SECRET
 create mode 100644 docker-compose-examples/grist-traefik-oidc-auth/secrets_template/SESSION_SECRET
 create mode 100644 docker-compose-examples/grist-traefik-oidc-auth/secrets_template/STORAGE_ENCRYPTION_KEY
 create mode 100644 docker-compose-examples/grist-traefik-oidc-auth/secrets_template/certs/private.pem
 create mode 100644 docker-compose-examples/grist-with-postgres-redis-minio/.env
 create mode 100644 docker-compose-examples/grist-with-postgres-redis-minio/README.md
 create mode 100644 docker-compose-examples/grist-with-postgres-redis-minio/docker-compose.yml

diff --git a/.gitignore b/.gitignore
index 3ec43ff9..95d698a2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -83,3 +83,8 @@ xunit.xml
 
 # ext directory can be overwritten
 ext/**
+
+# Docker compose examples - persistent values and secrets
+/docker-compose-examples/*/persist
+/docker-compose-examples/*/secrets
+/docker-compose-examples/grist-traefik-oidc-auth/.env
diff --git a/docker-compose-examples/grist-local-testing/README.md b/docker-compose-examples/grist-local-testing/README.md
new file mode 100644
index 00000000..cd610cfd
--- /dev/null
+++ b/docker-compose-examples/grist-local-testing/README.md
@@ -0,0 +1,12 @@
+This is the simplest example that runs Grist, suitable for local testing.
+
+It is STRONGLY RECOMMENDED not to use this container in a way that makes it accessible to the internet.
+This setup lacks basic security or authentication.
+
+Other examples demonstrate how to set up authentication and HTTPS.
+
+See https://support.getgrist.com/self-managed for more information.
+
+## How to run this example
+
+This example can be run with `docker compose up`.
\ No newline at end of file
diff --git a/docker-compose-examples/grist-local-testing/docker-compose.yml b/docker-compose-examples/grist-local-testing/docker-compose.yml
new file mode 100644
index 00000000..028d4e0f
--- /dev/null
+++ b/docker-compose-examples/grist-local-testing/docker-compose.yml
@@ -0,0 +1,8 @@
+services:
+  grist:
+    image: gristlabs/grist:latest
+    volumes:
+      # Where to store persistent data, such as documents.
+      - ${PERSIST_DIR}/grist:/persist
+    ports:
+      - 8484:8484
diff --git a/docker-compose-examples/grist-traefik-basic-auth/README.md b/docker-compose-examples/grist-traefik-basic-auth/README.md
new file mode 100644
index 00000000..170361c4
--- /dev/null
+++ b/docker-compose-examples/grist-traefik-basic-auth/README.md
@@ -0,0 +1,24 @@
+This is the simplest example of Grist with authentication and HTTPS encryption.
+
+It uses Traefik as:
+- A reverse proxy to manage certificates and provide HTTPS support
+- A basic authentication provided using Traefik's Basic Auth middleware.
+
+This setup, after configuring HTTPS certificates correctly, should be acceptable on the public internet.
+
+However, it doesn't allow a user to sign-out due to the way browsers handle basic authentication.
+
+You may want to try a more secure authentication setup such Authelia, Authentik or traefik-forward-auth.
+The OIDC auth example demonstrates a setup using Authelia.
+
+See https://support.getgrist.com/self-managed for more information.
+
+## How to run this example
+
+This example can be run with `docker compose up`.
+
+The default login is:
+- Username: `test@example.org`
+- Password: `test`
+
+This can be changed in `./configs/traefik-dynamic-config.yaml`. Instructions on how to do this are available in that file.
diff --git a/docker-compose-examples/grist-traefik-basic-auth/configs/traefik-config.yml b/docker-compose-examples/grist-traefik-basic-auth/configs/traefik-config.yml
new file mode 100644
index 00000000..ac1dd4ec
--- /dev/null
+++ b/docker-compose-examples/grist-traefik-basic-auth/configs/traefik-config.yml
@@ -0,0 +1,35 @@
+providers:
+  # Enables reading docker label config values
+  docker: {}
+  # Read additional config from this file.
+  file:
+    directory: "/etc/traefik/dynamic"
+
+entrypoints:
+  # Defines a secure entrypoint using TLS encryption
+  websecure:
+    address: ":443"
+    http:
+      tls: true
+  # Defines an insecure entrypoint that redirects to the secure one.
+  web:
+    address: ":80"
+    http:
+      # Redirects HTTP to HTTPS
+      redirections:
+        entrypoint:
+          to: "websecure"
+          scheme: "https"
+
+# Enables automatic certificate renewal
+certificatesResolvers:
+  letsencrypt:
+    acme:
+      email: "my_email@example.com"
+      storage: /acme/acme.json
+      tlschallenge: true
+
+# Enables the web UI
+# This is disabled by default for security, but can be useful to debugging traefik.
+api:
+  # insecure: true
diff --git a/docker-compose-examples/grist-traefik-basic-auth/configs/traefik-dynamic-config.yml b/docker-compose-examples/grist-traefik-basic-auth/configs/traefik-dynamic-config.yml
new file mode 100644
index 00000000..e4544c04
--- /dev/null
+++ b/docker-compose-examples/grist-traefik-basic-auth/configs/traefik-dynamic-config.yml
@@ -0,0 +1,13 @@
+http:
+  # Declaring the user list
+  middlewares:
+    grist-basic-auth:
+      basicAuth:
+        # The header that Grist will listen for authenticated usernames on.
+        headerField: "X-Forwarded-User"
+        # This is the list of users, in the format username:password.
+        # Passwords can be created using `htpasswd`
+        # E.g: `htpasswd -nB test@example.org`
+        users:
+          # The default username is "test@example.org". The default password is "test".
+          - "test@example.org:$apr1$H6uskkkW$IgXLP6ewTrSuBkTrqE8wj/"
diff --git a/docker-compose-examples/grist-traefik-basic-auth/docker-compose.yml b/docker-compose-examples/grist-traefik-basic-auth/docker-compose.yml
new file mode 100644
index 00000000..63048281
--- /dev/null
+++ b/docker-compose-examples/grist-traefik-basic-auth/docker-compose.yml
@@ -0,0 +1,44 @@
+services:
+  grist:
+    image: gristlabs/grist:latest
+    environment:
+      # Sets the header to look at for authentication
+      GRIST_FORWARD_AUTH_HEADER: X-Forwarded-User
+      # Forces Grist to only use a single team called 'Example'
+      GRIST_SINGLE_ORG: my-grist-team   # alternatively, GRIST_ORG_IN_PATH: "true" for multi-team operation
+      # Force users to login (disable anonymous access)
+      GRIST_FORCE_LOGIN: true
+      # Base URL Grist redirects to when navigating. Change this to your domain.
+      APP_HOME_URL: https://grist.localhost
+      # Default email for the "Admin" account
+      GRIST_DEFAULT_EMAIL: test@example.org
+    volumes:
+      # Where to store persistent data, such as documents.
+      - ${PERSIST_DIR}/grist:/persist
+    labels:
+      - "traefik.http.services.grist.loadbalancer.server.port=8484"
+      - "traefik.http.routers.grist.rule=Host(`grist.localhost`)"
+      - "traefik.http.routers.grist.tls.certresolver=letsencrypt"
+      - "traefik.http.routers.grist-auth.rule=Host(`grist.localhost`) && (PathPrefix(`/auth/login`) || PathPrefix(`/_oauth`))"
+      - "traefik.http.routers.grist-auth.middlewares=grist-basic-auth@file"
+      - "traefik.http.routers.grist-auth.tls.certresolver=letsencrypt"
+
+  traefik:
+    image: traefik:latest
+    ports:
+      # HTTP Ports
+      - "80:80"
+      - "443:443"
+      # The Web UI (enabled by --api.insecure=true)
+      # - "8080:8080"
+    volumes:
+      # Set the config file for traefik - this is loaded automatically.
+      - ./configs/traefik-config.yml:/etc/traefik/traefik.yml
+      # Set the config file for the dynamic config, such as middleware.
+      - ./configs/traefik-dynamic-config.yml:/etc/traefik/dynamic/dynamic-config.yml
+      # Certificate location, if automatic certificate setup is enabled.
+      - ./configs/acme:/acme
+      # Traefik needs docker access when configured via docker labels.
+      - /var/run/docker.sock:/var/run/docker.sock
+    depends_on:
+      - grist
diff --git a/docker-compose-examples/grist-traefik-oidc-auth/README.md b/docker-compose-examples/grist-traefik-oidc-auth/README.md
new file mode 100644
index 00000000..b861d00a
--- /dev/null
+++ b/docker-compose-examples/grist-traefik-oidc-auth/README.md
@@ -0,0 +1,32 @@
+This is an example of Grist with Authelia for OIDC authentication, and Traefik for HTTP encryption and routing.
+
+OIDC enables authentication using many existing providers, including Google, Microsoft, Amazon and Okta.
+
+This example uses Authelia, which is a locally hosted OIDC provider, so that it can work without further setup. 
+However, Authelia could be easily replaced by one of the providers listed above, or other self-hosted alternatives,
+such as Authentik or Dex.
+
+This example could be hosted on a dedicated server, with the following changes:
+- DNS setup
+- HTTPS / Certificate setup (e.g Let's encrypt)
+
+See https://support.getgrist.com/install/oidc for more information on using Grist with OIDC.
+
+## How to run this example
+
+To run this example, you'll first need to generate several secrets needed by Authelia.
+
+This is automated for you in `generateSecureSecrets.sh`, which uses Authelia's docker image to populate the `./secrets` directory.
+
+This example can then be run with `docker compose up`. This will make Grist available on `https://grist.localhost` with a self-signed certificate (by default), after all the services have started. Note: it may take up to a minute for all of the services to start correctly.
+
+The self-signed certificate will cause a security warning in the web browser when you try to visit Grist.
+This is fine for local testing and can be bypassed, but correct certificates should be set up if Grist is being made
+available on the internet.
+
+### Users
+
+The default username is `test`, with password `test`.
+
+You can add or modify users in ./configs/authelia/user-database.yml. Additional instructions are provided in that file.
+
diff --git a/docker-compose-examples/grist-traefik-oidc-auth/configs/authelia/configuration.yml b/docker-compose-examples/grist-traefik-oidc-auth/configs/authelia/configuration.yml
new file mode 100644
index 00000000..e5019d63
--- /dev/null
+++ b/docker-compose-examples/grist-traefik-oidc-auth/configs/authelia/configuration.yml
@@ -0,0 +1,1423 @@
+# yamllint disable rule:comments-indentation
+
+###############################################################################
+##         Original configuration file available at this URL:                ##
+##   https://github.com/authelia/authelia/blob/master/config.template.yml    ##
+##                                                                           ##
+##     This file is an edited version to support Grist as an OIDC client     ##
+###############################################################################
+
+#------------------------------------------------------------------------------
+
+###############################################################################
+##                           Authelia Configuration                          ##
+###############################################################################
+
+##
+## Notes:
+##
+##    - the default location of this file is assumed to be configuration.yml unless otherwise noted
+##    - when using docker the container expects this by default to be at /config/configuration.yml
+##    - the default location where this file is loaded from can be overridden with the X_AUTHELIA_CONFIG environment var
+##    - the comments in this configuration file are helpful but users should consult the official documentation on the
+##      website at https://www.authelia.com/ or https://www.authelia.com/configuration/prologue/introduction/
+##    - this configuration file template is not automatically updated
+##
+
+## Certificates directory specifies where Authelia will load trusted certificates (public portion) from in addition to
+## the system certificates store.
+## They should be in base64 format, and have one of the following extensions: *.cer, *.crt, *.pem.
+# certificates_directory: '/config/certificates/'
+
+## The theme to display: light, dark, grey, auto.
+# theme: 'light'
+
+## Set the default 2FA method for new users and for when a user has a preferred method configured that has been
+## disabled. This setting must be a method that is enabled.
+## Options are totp, webauthn, mobile_push.
+# default_2fa_method: ''
+
+##
+## Server Configuration
+##
+server:
+  ## The address for the Main server to listen on in the address common syntax.
+  ## Formats:
+  ##  - [<scheme>://]<hostname>[:<port>][/<path>]
+  ##  - [<scheme>://][hostname]:<port>[/<path>]
+  ## Square brackets indicate optional portions of the format. Scheme must be 'tcp', 'tcp4', 'tcp6', or 'unix'.
+  ## The default scheme is 'unix' if the address is an absolute path otherwise it's 'tcp'. The default port is '9091'.
+  ## If the path is specified this configures the router to handle both the `/` path and the configured path.
+  address: 'tcp://:9091/'
+
+  ## Set the path on disk to Authelia assets.
+  ## Useful to allow overriding of specific static assets.
+  # asset_path: '/config/assets/'
+
+  ## Disables writing the health check vars to /app/.healthcheck.env which makes healthcheck.sh return exit code 0.
+  ## This is disabled by default if either /app/.healthcheck.env or /app/healthcheck.sh do not exist.
+  # disable_healthcheck: false
+
+  ## Authelia by default doesn't accept TLS communication on the server port. This section overrides this behaviour.
+  # tls:
+    ## The path to the DER base64/PEM format private key.
+    # key: ''
+
+    ## The path to the DER base64/PEM format public certificate.
+    # certificate: ''
+
+    ## The list of certificates for client authentication.
+    # client_certificates: []
+
+  ## Server headers configuration/customization.
+  # headers:
+
+    ## The CSP Template. Read the docs.
+    # csp_template: ''
+
+  ## Server Buffers configuration.
+  # buffers:
+
+    ## Buffers usually should be configured to be the same value.
+    ## Explanation at https://www.authelia.com/c/server#buffer-sizes
+    ## Read buffer size adjusts the server's max incoming request size in bytes.
+    ## Write buffer size does the same for outgoing responses.
+
+    ## Read buffer.
+    # read: 4096
+
+    ## Write buffer.
+    # write: 4096
+
+  ## Server Timeouts configuration.
+  # timeouts:
+
+    ## Read timeout in the duration common syntax.
+    # read: '6 seconds'
+
+    ## Write timeout in the duration common syntax.
+    # write: '6 seconds'
+
+    ## Idle timeout in the duration common syntax.
+    # idle: '30 seconds'
+
+  ## Server Endpoints configuration.
+  ## This section is considered advanced and it SHOULD NOT be configured unless you've read the relevant documentation.
+  endpoints:
+    ## Enables the pprof endpoint.
+    # enable_pprof: false
+
+    ## Enables the expvars endpoint.
+    # enable_expvars: false
+
+    ## Configure the authz endpoints.
+    authz:
+      forward-auth:
+        implementation: 'ForwardAuth'
+        authn_strategies: []
+      # ext-authz:
+        # implementation: 'ExtAuthz'
+        # authn_strategies: []
+      # auth-request:
+        # implementation: 'AuthRequest'
+        # authn_strategies: []
+      # legacy:
+        # implementation: 'Legacy'
+        # authn_strategies: []
+
+##
+## Log Configuration
+##
+log:
+  ## Level of verbosity for logs: info, debug, trace.
+  level: 'debug'
+
+  ## Format the logs are written as: json, text.
+  # format: 'json'
+
+  ## File path where the logs will be written. If not set logs are written to stdout.
+  # file_path: '/config/authelia.log'
+
+  ## Whether to also log to stdout when a log_file_path is defined.
+  # keep_stdout: false
+
+##
+## Telemetry Configuration
+##
+telemetry:
+
+  ##
+  ## Metrics Configuration
+  ##
+  metrics:
+    ## Enable Metrics.
+    enabled: false
+
+    ## The address for the Metrics server to listen on in the address common syntax.
+    ## Formats:
+    ##  - [<scheme>://]<hostname>[:<port>][/<path>]
+    ##  - [<scheme>://][hostname]:<port>[/<path>]
+    ## Square brackets indicate optional portions of the format. Scheme must be 'tcp', 'tcp4', 'tcp6', or 'unix'.
+    ## The default scheme is 'unix' if the address is an absolute path otherwise it's 'tcp'. The default port is '9959'.
+    ## If the path is not specified it defaults to `/metrics`.
+    # address: 'tcp://:9959/metrics'
+
+    ## Metrics Server Buffers configuration.
+    # buffers:
+
+      ## Read buffer.
+      # read: 4096
+
+      ## Write buffer.
+      # write: 4096
+
+    ## Metrics Server Timeouts configuration.
+    # timeouts:
+
+      ## Read timeout in the duration common syntax.
+      # read: '6 seconds'
+
+      ## Write timeout in the duration common syntax.
+      # write: '6 seconds'
+
+      ## Idle timeout in the duration common syntax.
+      # idle: '30 seconds'
+
+##
+## TOTP Configuration
+##
+## Parameters used for TOTP generation.
+totp:
+  ## Disable TOTP.
+  disable: false
+
+  ## The issuer name displayed in the Authenticator application of your choice.
+  # issuer: 'authelia.com'
+
+  ## The TOTP algorithm to use.
+  ## It is CRITICAL you read the documentation before changing this option:
+  ## https://www.authelia.com/c/totp#algorithm
+  # algorithm: 'SHA1'
+
+  ## The number of digits a user has to input. Must either be 6 or 8.
+  ## Changing this option only affects newly generated TOTP configurations.
+  ## It is CRITICAL you read the documentation before changing this option:
+  ## https://www.authelia.com/c/totp#digits
+  # digits: 6
+
+  ## The period in seconds a Time-based One-Time Password is valid for.
+  ## Changing this option only affects newly generated TOTP configurations.
+  # period: 30
+
+  ## The skew controls number of Time-based One-Time Passwords either side of the current one that are valid.
+  ## Warning: before changing skew read the docs link below.
+  # skew: 1
+  ## See: https://www.authelia.com/c/totp#input-validation to read
+  ## the documentation.
+
+  ## The size of the generated shared secrets. Default is 32 and is sufficient in most use cases, minimum is 20.
+  # secret_size: 32
+
+  ## The allowed algorithms for a user to pick from.
+  # allowed_algorithms:
+  # - 'SHA1'
+
+  ## The allowed digits for a user to pick from.
+  # allowed_digits:
+  # - 6
+
+  ## The allowed periods for a user to pick from.
+  # allowed_periods:
+  # - 30
+
+  ## Disable the reuse security policy which prevents replays of one-time password code values.
+  # disable_reuse_security_policy: false
+
+##
+## WebAuthn Configuration
+##
+## Parameters used for WebAuthn.
+webauthn:
+  ## Disable WebAuthn.
+  disable: false
+
+  ## The interaction timeout for WebAuthn dialogues in the duration common syntax.
+  # timeout: '60 seconds'
+
+  ## The display name the browser should show the user for when using WebAuthn to login/register.
+  # display_name: 'Authelia'
+
+  ## Conveyance preference controls if we collect the attestation statement including the AAGUID from the device.
+  ## Options are none, indirect, direct.
+  # attestation_conveyance_preference: 'indirect'
+
+  ## User verification controls if the user must make a gesture or action to confirm they are present.
+  ## Options are required, preferred, discouraged.
+  # user_verification: 'preferred'
+
+##
+## Duo Push API Configuration
+##
+## Parameters used to contact the Duo API. Those are generated when you protect an application of type
+## "Partner Auth API" in the management panel.
+# duo_api:
+  # disable: false
+  # hostname: 'api-123456789.example.com'
+  # integration_key: 'ABCDEF'
+  ## Secret can also be set using a secret: https://www.authelia.com/c/secrets
+  # secret_key: '1234567890abcdefghifjkl'
+  # enable_self_enrollment: false
+
+##
+## Identity Validation Configuration
+##
+## This configuration tunes the identity validation flows.
+identity_validation:
+
+  ## Reset Password flow. Adjusts how the reset password flow operates.
+  reset_password:
+    ## Maximum allowed time before the JWT is generated and when the user uses it in the duration common syntax.
+    # jwt_lifespan: '5 minutes'
+
+    ## The algorithm used for the Reset Password JWT.
+    # jwt_algorithm: 'HS256'
+
+    ## The secret key used to sign and verify the JWT.
+    # jwt_secret: 'a_very_important_secret'
+
+  ## Elevated Session flows. Adjusts the flow which require elevated sessions for example managing credentials, adding,
+  ## removing, etc.
+  # elevated_session:
+    ## Maximum allowed lifetime after the One-Time Code is generated that it is considered valid.
+    # code_lifespan: '5 minutes'
+
+    ## Maximum allowed lifetime after the user uses the One-Time Code and the user must perform the validation again in
+    ## the duration common syntax.
+    # elevation_lifespan: '10 minutes'
+
+    ## Number of characters the one-time password contains.
+    # characters: 8
+
+    ## In addition to the One-Time Code requires the user performs a second factor authentication.
+    # require_second_factor: false
+
+    ## Skips the elevation requirement and entry of the One-Time Code if the user has performed second factor
+    ## authentication.
+    # skip_second_factor: false
+
+##
+## NTP Configuration
+##
+## This is used to validate the servers time is accurate enough to validate TOTP.
+# ntp:
+  ## The address of the NTP server to connect to in the address common syntax.
+  ## Format: [<scheme>://]<hostname>[:<port>].
+  ## Square brackets indicate optional portions of the format. Scheme must be 'udp', 'udp4', or 'udp6'.
+  ## The default scheme is 'udp'. The default port is '123'.
+  # address: 'udp://time.cloudflare.com:123'
+
+  ## NTP version.
+  # version: 4
+
+  ## Maximum allowed time offset between the host and the NTP server in the duration common syntax.
+  # max_desync: '3 seconds'
+
+  ## Disables the NTP check on startup entirely. This means Authelia will not contact a remote service at all if you
+  ## set this to true, and can operate in a truly offline mode.
+  # disable_startup_check: false
+
+  ## The default of false will prevent startup only if we can contact the NTP server and the time is out of sync with
+  ## the NTP server more than the configured max_desync. If you set this to true, an error will be logged but startup
+  ## will continue regardless of results.
+  # disable_failure: false
+
+##
+## Authentication Backend Provider Configuration
+##
+## Used for verifying user passwords and retrieve information such as email address and groups users belong to.
+##
+## The available providers are: `file`, `ldap`. You must use only one of these providers.
+authentication_backend:
+  ## Password Reset Options.
+  password_reset:
+    ## Disable both the HTML element and the API for reset password functionality.
+    disable: false
+
+    ## External reset password url that redirects the user to an external reset portal. This disables the internal reset
+    ## functionality.
+    # custom_url: ''
+
+  ## The amount of time to wait before we refresh data from the authentication backend in the duration common syntax.
+  ## To disable this feature set it to 'disable', this will slightly reduce security because for Authelia, users will
+  ## always belong to groups they belonged to at the time of login even if they have been removed from them in LDAP.
+  ## To force update on every request you can set this to '0' or 'always', this will increase processor demand.
+  ## See the below documentation for more information.
+  ## Refresh Interval docs: https://www.authelia.com/c/1fa#refresh-interval
+  # refresh_interval: '5 minutes'
+
+  ##
+  ## LDAP (Authentication Provider)
+  ##
+  ## This is the recommended Authentication Provider in production
+  ## because it allows Authelia to offload the stateful operations
+  ## onto the LDAP service.
+  # ldap:
+    ## The address of the directory server to connect to in the address common syntax.
+    ## Format: [<scheme>://]<hostname>[:<port>].
+    ## Square brackets indicate optional portions of the format. Scheme must be 'ldap', 'ldaps', or 'ldapi`.
+    ## The default scheme is 'ldapi' if the address is an absolute path otherwise it's 'ldaps'.
+    ## The default port is '636', unless the scheme is 'ldap' in which case it's '389'.
+    # address: 'ldaps://127.0.0.1:636'
+
+    ## The LDAP implementation, this affects elements like the attribute utilised for resetting a password.
+    ## Acceptable options are as follows:
+    ## - 'activedirectory' - for Microsoft Active Directory.
+    ## - 'freeipa' - for FreeIPA.
+    ## - 'lldap' - for lldap.
+    ## - 'custom' - for custom specifications of attributes and filters.
+    ## This currently defaults to 'custom' to maintain existing behaviour.
+    ##
+    ## Depending on the option here certain other values in this section have a default value, notably all of the
+    ## attribute mappings have a default value that this config overrides, you can read more about these default values
+    ## at https://www.authelia.com/c/ldap#defaults
+    # implementation: 'custom'
+
+    ## The dial timeout for LDAP in the duration common syntax.
+    # timeout: '5 seconds'
+
+    ## Use StartTLS with the LDAP connection.
+    # start_tls: false
+
+    # tls:
+      ## The server subject name to check the servers certificate against during the validation process.
+      ## This option is not required if the certificate has a SAN which matches the address options hostname.
+      # server_name: 'ldap.example.com'
+
+      ## Skip verifying the server certificate entirely. In preference to setting this we strongly recommend you add the
+      ## certificate or the certificate of the authority signing the certificate to the certificates directory which is
+      ## defined by the `certificates_directory` option at the top of the configuration.
+      ## It's important to note the public key should be added to the directory, not the private key.
+      ## This option is strongly discouraged but may be useful in some self-signed situations where validation is not
+      ## important to the administrator.
+      # skip_verify: false
+
+      ## Minimum TLS version for the connection.
+      # minimum_version: 'TLS1.2'
+
+      ## Maximum TLS version for the connection.
+      # maximum_version: 'TLS1.3'
+
+      ## The certificate chain used with the private_key if the server requests TLS Client Authentication
+      ## i.e. Mutual TLS.
+      # certificate_chain: |
+        # -----BEGIN CERTIFICATE-----
+        # ...
+        # -----END CERTIFICATE-----
+        # -----BEGIN CERTIFICATE-----
+        # ...
+        # -----END CERTIFICATE-----
+
+      ## The private key used with the certificate_chain if the server requests TLS Client Authentication
+      ## i.e. Mutual TLS.
+      # private_key: |
+        # -----BEGIN RSA PRIVATE KEY-----
+        # ...
+        # -----END RSA PRIVATE KEY-----
+
+    ## The distinguished name of the container searched for objects in the directory information tree.
+    ## See also: additional_users_dn, additional_groups_dn.
+    # base_dn: 'dc=example,dc=com'
+
+    ## The additional_users_dn is prefixed to base_dn and delimited by a comma when searching for users.
+    ## i.e. with this set to OU=Users and base_dn set to DC=a,DC=com; OU=Users,DC=a,DC=com is searched for users.
+    # additional_users_dn: 'ou=users'
+
+    ## The users filter used in search queries to find the user profile based on input filled in login form.
+    ## Various placeholders are available in the user filter which you can read about in the documentation which can
+    ## be found at: https://www.authelia.com/c/ldap#users-filter-replacements
+    ##
+    ## Recommended settings are as follows:
+    ## - Microsoft Active Directory: (&({username_attribute}={input})(objectCategory=person)(objectClass=user))
+    ## - OpenLDAP:
+    ##   - (&({username_attribute}={input})(objectClass=person))
+    ##   - (&({username_attribute}={input})(objectClass=inetOrgPerson))
+    ##
+    ## To allow sign in both with username and email, one can use a filter like
+    ## (&(|({username_attribute}={input})({mail_attribute}={input}))(objectClass=person))
+    # users_filter: '(&({username_attribute}={input})(objectClass=person))'
+
+    ## The additional_groups_dn is prefixed to base_dn and delimited by a comma when searching for groups.
+    ## i.e. with this set to OU=Groups and base_dn set to DC=a,DC=com; OU=Groups,DC=a,DC=com is searched for groups.
+    # additional_groups_dn: 'ou=groups'
+
+    ## The groups filter used in search queries to find the groups based on relevant authenticated user.
+    ## Various placeholders are available in the groups filter which you can read about in the documentation which can
+    ## be found at: https://www.authelia.com/c/ldap#groups-filter-replacements
+    ##
+    ## If your groups use the `groupOfUniqueNames` structure use this instead:
+    ##    (&(uniqueMember={dn})(objectClass=groupOfUniqueNames))
+    # groups_filter: '(&(member={dn})(objectClass=groupOfNames))'
+
+    ## The group search mode to use. Options are 'filter' or 'memberof'. It's essential to read the docs if you wish to
+    ## use 'memberof'. Also 'filter' is the best choice for most use cases.
+    # group_search_mode: 'filter'
+
+    ## Follow referrals returned by the server.
+    ## This is especially useful for environments where read-only servers exist. Only implemented for write operations.
+    # permit_referrals: false
+
+    ## The username and password of the admin user.
+    # user: 'cn=admin,dc=example,dc=com'
+    ## Password can also be set using a secret: https://www.authelia.com/c/secrets
+    # password: 'password'
+
+    ## The attributes for users and objects from the directory server.
+    # attributes:
+
+      ## The distinguished name attribute if your directory server supports it. Users should read the docs before
+      ## configuring. Only used for the 'memberof' group search mode.
+      # distinguished_name: ''
+
+      ## The attribute holding the username of the user. This attribute is used to populate the username in the session
+      ## information. For your information, Microsoft Active Directory usually uses 'sAMAccountName' and OpenLDAP
+      ## usually uses 'uid'. Beware that this attribute holds the unique identifiers for the users binding the user and
+      ## the configuration stored in database; therefore only single value attributes are allowed and the value must
+      ## never be changed once attributed to a user otherwise it would break the configuration for that user.
+      ## Technically non-unique attributes like 'mail' can also be used but we don't recommend using them, we instead
+      ## advise to use a filter to perform alternative lookups and the attributes mentioned above
+      ## (sAMAccountName and uid) to follow https://datatracker.ietf.org/doc/html/rfc2307.
+      # username: 'uid'
+
+      ## The attribute holding the display name of the user. This will be used to greet an authenticated user.
+      # display_name: 'displayName'
+
+      ## The attribute holding the mail address of the user. If multiple email addresses are defined for a user, only
+      ## the first one returned by the directory server is used.
+      # mail: 'mail'
+
+      ## The attribute which provides distinguished names of groups an object is a member of.
+      ## Only used for the 'memberof' group search mode.
+      # member_of: 'memberOf'
+
+      ## The attribute holding the name of the group.
+      # group_name: 'cn'
+
+  ##
+  ## File (Authentication Provider)
+  ##
+  ## With this backend, the users database is stored in a file which is updated when users reset their passwords.
+  ## Therefore, this backend is meant to be used in a dev environment and not in production since it prevents Authelia
+  ## to be scaled to more than one instance. The options under 'password' have sane defaults, and as it has security
+  ## implications it is highly recommended you leave the default values. Before considering changing these settings
+  ## please read the docs page below:
+  ## https://www.authelia.com/r/passwords#tuning
+  ##
+  ## Important: Kubernetes (or HA) users must read https://www.authelia.com/t/statelessness
+  ##
+  file:
+    path: '/config/users_database.yml'
+    watch: true
+    search:
+      email: false
+      case_insensitive: false
+    #password:
+      #algorithm: 'argon2'
+      # argon2:
+        # variant: 'argon2id'
+        # iterations: 3
+        # memory: 65536
+        # parallelism: 4
+        # key_length: 32
+        # salt_length: 16
+      # scrypt:
+        # iterations: 16
+        # block_size: 8
+        # parallelism: 1
+        # key_length: 32
+        # salt_length: 16
+      # pbkdf2:
+        # variant: 'sha512'
+        # iterations: 310000
+        # salt_length: 16
+      # sha2crypt:
+        # variant: 'sha512'
+        # iterations: 50000
+        # salt_length: 16
+      # bcrypt:
+        # variant: 'standard'
+        # cost: 12
+
+##
+## Password Policy Configuration.
+##
+password_policy:
+
+  ## The standard policy allows you to tune individual settings manually.
+  standard:
+    enabled: false
+
+    ## Require a minimum length for passwords.
+    min_length: 8
+
+    ## Require a maximum length for passwords.
+    max_length: 0
+
+    ## Require uppercase characters.
+    require_uppercase: true
+
+    ## Require lowercase characters.
+    require_lowercase: true
+
+    ## Require numeric characters.
+    require_number: true
+
+    ## Require special characters.
+    require_special: true
+
+  ## zxcvbn is a well known and used password strength algorithm. It does not have tunable settings.
+  zxcvbn:
+    enabled: false
+
+    ## Configures the minimum score allowed.
+    min_score: 3
+
+##
+## Privacy Policy Configuration
+##
+## Parameters used for displaying the privacy policy link and drawer.
+privacy_policy:
+
+  ## Enables the display of the privacy policy using the policy_url.
+  enabled: false
+
+  ## Enables the display of the privacy policy drawer which requires users accept the privacy policy
+  ## on a per-browser basis.
+  require_user_acceptance: false
+
+  ## The URL of the privacy policy document. Must be an absolute URL and must have the 'https://' scheme.
+  ## If the privacy policy enabled option is true, this MUST be provided.
+  policy_url: ''
+
+##
+## Access Control Configuration
+##
+## Access control is a list of rules defining the authorizations applied for one resource to users or group of users.
+##
+## If 'access_control' is not defined, ACL rules are disabled and the 'bypass' rule is applied, i.e., access is allowed
+## to anyone. Otherwise restrictions follow the rules defined.
+##
+## Note: One can use the wildcard * to match any subdomain.
+## It must stand at the beginning of the pattern. (example: *.example.com)
+##
+## Note: You must put patterns containing wildcards between simple quotes for the YAML to be syntactically correct.
+##
+## Definition: A 'rule' is an object with the following keys: 'domain', 'subject', 'policy' and 'resources'.
+##
+## - 'domain' defines which domain or set of domains the rule applies to.
+##
+## - 'subject' defines the subject to apply authorizations to. This parameter is optional and matching any user if not
+##    provided. If provided, the parameter represents either a user or a group. It should be of the form
+##    'user:<username>' or 'group:<groupname>'.
+##
+## - 'policy' is the policy to apply to resources. It must be either 'bypass', 'one_factor', 'two_factor' or 'deny'.
+##
+## - 'resources' is a list of regular expressions that matches a set of resources to apply the policy to. This parameter
+##   is optional and matches any resource if not provided.
+##
+## Note: the order of the rules is important. The first policy matching (domain, resource, subject) applies.
+access_control:
+  ## Default policy can either be 'bypass', 'one_factor', 'two_factor' or 'deny'. It is the policy applied to any
+  ## resource if there is no policy to be applied to the user.
+  default_policy: 'one_factor'
+
+  # networks:
+    # - name: 'internal'
+    #   networks:
+        # - '10.10.0.0/16'
+        # - '192.168.2.0/24'
+    # - name: 'VPN'
+    #   networks: '10.9.0.0/16'
+
+  # rules:
+    ## Rules applied to everyone
+    # - domain: 'public.example.com'
+    #   policy: 'bypass'
+
+    ## Domain Regex examples. Generally we recommend just using a standard domain.
+    # - domain_regex: '^(?P<User>\w+)\.example\.com$'
+    #   policy: 'one_factor'
+    # - domain_regex: '^(?P<Group>\w+)\.example\.com$'
+    #   policy: 'one_factor'
+    # - domain_regex:
+      #  - '^appgroup-.*\.example\.com$'
+      #  - '^appgroup2-.*\.example\.com$'
+    #   policy: 'one_factor'
+    # - domain_regex: '^.*\.example\.com$'
+    #   policy: 'two_factor'
+
+    # - domain: 'secure.example.com'
+    #   policy: 'one_factor'
+    ## Network based rule, if not provided any network matches.
+    #   networks:
+        # - 'internal'
+        # - 'VPN'
+        # - '192.168.1.0/24'
+        # - '10.0.0.1'
+
+    # - domain:
+        # - 'secure.example.com'
+        # - 'private.example.com'
+    #   policy: 'two_factor'
+
+    # - domain: 'singlefactor.example.com'
+    #   policy: 'one_factor'
+
+    ## Rules applied to 'admins' group
+    # - domain: 'mx2.mail.example.com'
+    #   subject: 'group:admins'
+    #   policy: 'deny'
+
+    # - domain: '*.example.com'
+    #   subject:
+        # - 'group:admins'
+        # - 'group:moderators'
+    #   policy: 'two_factor'
+
+    ## Rules applied to 'dev' group
+    # - domain: 'dev.example.com'
+    #   resources:
+        # - '^/groups/dev/.*$'
+    #   subject: 'group:dev'
+    #   policy: 'two_factor'
+
+    ## Rules applied to user 'john'
+    # - domain: 'dev.example.com'
+    #   resources:
+        # - '^/users/john/.*$'
+    #   subject: 'user:john'
+    #   policy: 'two_factor'
+
+    ## Rules applied to user 'harry'
+    # - domain: 'dev.example.com'
+    #   resources:
+        # - '^/users/harry/.*$'
+    #   subject: 'user:harry'
+    #   policy: 'two_factor'
+
+    ## Rules applied to user 'bob'
+    # - domain: '*.mail.example.com'
+    #   subject: 'user:bob'
+    #   policy: 'two_factor'
+    # - domain: 'dev.example.com'
+    #   resources:
+    #     - '^/users/bob/.*$'
+    #   subject: 'user:bob'
+    #   policy: 'two_factor'
+
+##
+## Session Provider Configuration
+##
+## The session cookies identify the user once logged in.
+## The available providers are: `memory`, `redis`. Memory is the provider unless redis is defined.
+session:
+  ## The secret to encrypt the session data. This is only used with Redis / Redis Sentinel.
+  ## Secret can also be set using a secret: https://www.authelia.com/c/secrets
+  # secret: 'insecure_session_secret'
+
+  ## Cookies configures the list of allowed cookie domains for sessions to be created on.
+  ## Undefined values will default to the values below.
+  cookies:
+    -
+      ## The name of the session cookie.
+      #name: 'authelia_session'
+
+      ## The domain to protect.
+      ## Note: the Authelia portal must also be in that domain.
+      domain: '{{ mustEnv "APP_DOMAIN" }}'
+
+      ## Required. The fully qualified URI of the portal to redirect users to on proxies that support redirections.
+      ## Rules:
+      ##   - MUST use the secure scheme 'https://'
+      ##   - The above 'domain' option MUST either:
+      ##      - Match the host portion of this URI.
+      ##      - Match the suffix of the host portion when prefixed with '.'.
+      authelia_url: 'https://auth.{{ mustEnv "APP_DOMAIN" }}'
+
+      ## Optional. The fully qualified URI used as the redirection location if the portal is accessed directly. Not
+      ## configuring this option disables the automatic redirection behaviour.
+      ##
+      ## Note: this parameter is optional. If not provided, user won't be redirected upon successful authentication
+      ## unless they were redirected to Authelia by the proxy.
+      ##
+      ## Rules:
+      ##   - MUST use the secure scheme 'https://'
+      ##   - MUST not match the 'authelia_url' option.
+      ##   - The above 'domain' option MUST either:
+      ##      - Match the host portion of this URI.
+      ##      - Match the suffix of the host portion when prefixed with '.'.
+      default_redirection_url: 'https://{{ mustEnv "APP_DOMAIN" }}'
+
+      ## Sets the Cookie SameSite value. Possible options are none, lax, or strict.
+      ## Please read https://www.authelia.com/c/session#same_site
+      # same_site: 'lax'
+
+      ## The value for inactivity, expiration, and remember_me are in seconds or the duration common syntax.
+      ## All three of these values affect the cookie/session validity period. Longer periods are considered less secure
+      ## because a stolen cookie will last longer giving attackers more time to spy or attack.
+
+      ## The inactivity time before the session is reset. If expiration is set to 1h, and this is set to 5m, if the user
+      ## does not select the remember me option their session will get destroyed after 1h, or after 5m since the last
+      ## time Authelia detected user activity.
+      # inactivity: '5 minutes'
+
+      ## The time before the session cookie expires and the session is destroyed if remember me IS NOT selected by the
+      ## user.
+      # expiration: '1 hour'
+
+      ## The time before the cookie expires and the session is destroyed if remember me IS selected by the user. Setting
+      ## this value to -1 disables remember me for this session cookie domain. If allowed and the user uses the remember
+      ## me checkbox this overrides the expiration option and disables the inactivity option.
+      # remember_me: '1 month'
+
+  ## Cookie Session Domain default 'name' value.
+  name: 'authelia_session'
+
+  ## Cookie Session Domain default 'same_site' value.
+  same_site: 'lax'
+
+  ## Cookie Session Domain default 'inactivity' value.
+  inactivity: '5m'
+
+  ## Cookie Session Domain default 'expiration' value.
+  expiration: '1h'
+
+  ## Cookie Session Domain default 'remember_me' value.
+  remember_me: '1M'
+
+  ##
+  ## Redis Provider
+  ##
+  ## Important: Kubernetes (or HA) users must read https://www.authelia.com/t/statelessness
+  ##
+  # redis:
+    # host: '127.0.0.1'
+    # port: 6379
+    ## Use a unix socket instead
+    # host: '/var/run/redis/redis.sock'
+
+    ## Username used for redis authentication. This is optional and a new feature in redis 6.0.
+    # username: 'authelia'
+
+    ## Password can also be set using a secret: https://www.authelia.com/c/secrets
+    # password: 'authelia'
+
+    ## This is the Redis DB Index https://redis.io/commands/select (sometimes referred to as database number, DB, etc).
+    # database_index: 0
+
+    ## The maximum number of concurrent active connections to Redis.
+    # maximum_active_connections: 8
+
+    ## The target number of idle connections to have open ready for work. Useful when opening connections is slow.
+    # minimum_idle_connections: 0
+
+    ## The Redis TLS configuration. If defined will require a TLS connection to the Redis instance(s).
+    # tls:
+      ## The server subject name to check the servers certificate against during the validation process.
+      ## This option is not required if the certificate has a SAN which matches the host option.
+      # server_name: 'myredis.example.com'
+
+      ## Skip verifying the server certificate entirely. In preference to setting this we strongly recommend you add the
+      ## certificate or the certificate of the authority signing the certificate to the certificates directory which is
+      ## defined by the `certificates_directory` option at the top of the configuration.
+      ## It's important to note the public key should be added to the directory, not the private key.
+      ## This option is strongly discouraged but may be useful in some self-signed situations where validation is not
+      ## important to the administrator.
+      # skip_verify: false
+
+      ## Minimum TLS version for the connection.
+      # minimum_version: 'TLS1.2'
+
+      ## Maximum TLS version for the connection.
+      # maximum_version: 'TLS1.3'
+
+      ## The certificate chain used with the private_key if the server requests TLS Client Authentication
+      ## i.e. Mutual TLS.
+      # certificate_chain: |
+        # -----BEGIN CERTIFICATE-----
+        # ...
+        # -----END CERTIFICATE-----
+        # -----BEGIN CERTIFICATE-----
+        # ...
+        # -----END CERTIFICATE-----
+
+      ## The private key used with the certificate_chain if the server requests TLS Client Authentication
+      ## i.e. Mutual TLS.
+      # private_key: |
+        # -----BEGIN RSA PRIVATE KEY-----
+        # ...
+        # -----END RSA PRIVATE KEY-----
+
+    ## The Redis HA configuration options.
+    ## This provides specific options to Redis Sentinel, sentinel_name must be defined (Master Name).
+    # high_availability:
+      ## Sentinel Name / Master Name.
+      # sentinel_name: 'mysentinel'
+
+      ## Specific username for Redis Sentinel. The node username and password is configured above.
+      # sentinel_username: 'sentinel_specific_user'
+
+      ## Specific password for Redis Sentinel. The node username and password is configured above.
+      # sentinel_password: 'sentinel_specific_pass'
+
+      ## The additional nodes to pre-seed the redis provider with (for sentinel).
+      ## If the host in the above section is defined, it will be combined with this list to connect to sentinel.
+      ## For high availability to be used you must have either defined; the host above or at least one node below.
+      # nodes:
+        # - host: 'sentinel-node1'
+        #   port: 6379
+        # - host: 'sentinel-node2'
+        #   port: 6379
+
+      ## Choose the host with the lowest latency.
+      # route_by_latency: false
+
+      ## Choose the host randomly.
+      # route_randomly: false
+
+##
+## Regulation Configuration
+##
+## This mechanism prevents attackers from brute forcing the first factor. It bans the user if too many attempts are made
+## in a short period of time.
+# regulation:
+  ## The number of failed login attempts before user is banned. Set it to 0 to disable regulation.
+  # max_retries: 3
+
+  ## The time range during which the user can attempt login before being banned in the duration common syntax. The user
+  ## is banned if the authentication failed 'max_retries' times in a 'find_time' seconds window.
+  # find_time: '2 minutes'
+
+  ## The length of time before a banned user can login again in the duration common syntax.
+  # ban_time: '5 minutes'
+
+##
+## Storage Provider Configuration
+##
+## The available providers are: `local`, `mysql`, `postgres`. You must use one and only one of these providers.
+storage:
+  ## The encryption key that is used to encrypt sensitive information in the database. Must be a string with a minimum
+  ## length of 20. Please see the docs if you configure this with an undesirable key and need to change it, you MUST use
+  ## the CLI to change this in the database if you want to change it from a previously configured value.
+  # encryption_key: 'you_must_generate_a_random_string_of_more_than_twenty_chars_and_configure_this'
+
+  ##
+  ## Local (Storage Provider)
+  ##
+  ## This stores the data in a SQLite3 Database.
+  ## This is only recommended for lightweight non-stateful installations.
+  ##
+  ## Important: Kubernetes (or HA) users must read https://www.authelia.com/t/statelessness
+  ##
+  local:
+    ## Path to the SQLite3 Database.
+    path: '/persist/db.sqlite3'
+
+  ##
+  ## MySQL / MariaDB (Storage Provider)
+  ##
+  # mysql:
+    ## The address of the MySQL server to connect to in the address common syntax.
+    ## Format: [<scheme>://]<hostname>[:<port>].
+    ## Square brackets indicate optional portions of the format. Scheme must be 'tcp', 'tcp4', 'tcp6', or 'unix`.
+    ## The default scheme is 'unix' if the address is an absolute path otherwise it's 'tcp'. The default port is '3306'.
+    # address: 'tcp://127.0.0.1:3306'
+
+    ## The database name to use.
+    # database: 'authelia'
+
+    ## The username used for SQL authentication.
+    # username: 'authelia'
+
+    ## The password used for SQL authentication.
+    ## Can also be set using a secret: https://www.authelia.com/c/secrets
+    # password: 'mypassword'
+
+    ## The connection timeout in the duration common syntax.
+    # timeout: '5 seconds'
+
+    ## MySQL TLS settings. Configuring this requires TLS.
+    # tls:
+      ## The server subject name to check the servers certificate against during the validation process.
+      ## This option is not required if the certificate has a SAN which matches the address options hostname.
+      # server_name: 'mysql.example.com'
+
+      ## Skip verifying the server certificate entirely. In preference to setting this we strongly recommend you add the
+      ## certificate or the certificate of the authority signing the certificate to the certificates directory which is
+      ## defined by the `certificates_directory` option at the top of the configuration.
+      ## It's important to note the public key should be added to the directory, not the private key.
+      ## This option is strongly discouraged but may be useful in some self-signed situations where validation is not
+      ## important to the administrator.
+      # skip_verify: false
+
+      ## Minimum TLS version for the connection.
+      # minimum_version: 'TLS1.2'
+
+      ## Maximum TLS version for the connection.
+      # maximum_version: 'TLS1.3'
+
+      ## The certificate chain used with the private_key if the server requests TLS Client Authentication
+      ## i.e. Mutual TLS.
+      # certificate_chain: |
+        # -----BEGIN CERTIFICATE-----
+        # ...
+        # -----END CERTIFICATE-----
+        # -----BEGIN CERTIFICATE-----
+        # ...
+        # -----END CERTIFICATE-----
+
+      ## The private key used with the certificate_chain if the server requests TLS Client Authentication
+      ## i.e. Mutual TLS.
+      # private_key: |
+        # -----BEGIN RSA PRIVATE KEY-----
+        # ...
+        # -----END RSA PRIVATE KEY-----
+
+  ##
+  ## PostgreSQL (Storage Provider)
+  ##
+  # postgres:
+    ## The address of the PostgreSQL server to connect to in the address common syntax.
+    ## Format: [<scheme>://]<hostname>[:<port>].
+    ## Square brackets indicate optional portions of the format. Scheme must be 'tcp', 'tcp4', 'tcp6', or 'unix`.
+    ## The default scheme is 'unix' if the address is an absolute path otherwise it's 'tcp'. The default port is '5432'.
+    # address: 'tcp://127.0.0.1:5432'
+
+    ## The database name to use.
+    # database: 'authelia'
+
+    ## The schema name to use.
+    # schema: 'public'
+
+    ## The username used for SQL authentication.
+    # username: 'authelia'
+
+    ## The password used for SQL authentication.
+    ## Can also be set using a secret: https://www.authelia.com/c/secrets
+    # password: 'mypassword'
+
+    ## The connection timeout in the duration common syntax.
+    # timeout: '5 seconds'
+
+    ## PostgreSQL TLS settings. Configuring this requires TLS.
+    # tls:
+      ## The server subject name to check the servers certificate against during the validation process.
+      ## This option is not required if the certificate has a SAN which matches the address options hostname.
+      # server_name: 'postgres.example.com'
+
+      ## Skip verifying the server certificate entirely. In preference to setting this we strongly recommend you add the
+      ## certificate or the certificate of the authority signing the certificate to the certificates directory which is
+      ## defined by the `certificates_directory` option at the top of the configuration.
+      ## It's important to note the public key should be added to the directory, not the private key.
+      ## This option is strongly discouraged but may be useful in some self-signed situations where validation is not
+      ## important to the administrator.
+      # skip_verify: false
+
+      ## Minimum TLS version for the connection.
+      # minimum_version: 'TLS1.2'
+
+      ## Maximum TLS version for the connection.
+      # maximum_version: 'TLS1.3'
+
+      ## The certificate chain used with the private_key if the server requests TLS Client Authentication
+      ## i.e. Mutual TLS.
+      # certificate_chain: |
+        # -----BEGIN CERTIFICATE-----
+        # ...
+        # -----END CERTIFICATE-----
+        # -----BEGIN CERTIFICATE-----
+        # ...
+        # -----END CERTIFICATE-----
+
+      ## The private key used with the certificate_chain if the server requests TLS Client Authentication
+      ## i.e. Mutual TLS.
+      # private_key: |
+        # -----BEGIN RSA PRIVATE KEY-----
+        # ...
+        # -----END RSA PRIVATE KEY-----
+
+##
+## Notification Provider
+##
+## Notifications are sent to users when they require a password reset, a WebAuthn registration or a TOTP registration.
+## The available providers are: filesystem, smtp. You must use only one of these providers.
+notifier:
+  ## You can disable the notifier startup check by setting this to true.
+  disable_startup_check: false
+
+  ##
+  ## File System (Notification Provider)
+  ##
+  ## Important: Kubernetes (or HA) users must read https://www.authelia.com/t/statelessness
+  ##
+  filesystem:
+    filename: '/persist/notification.txt'
+
+  ##
+  ## SMTP (Notification Provider)
+  ##
+  ## Use a SMTP server for sending notifications. Authelia uses the PLAIN or LOGIN methods to authenticate.
+  ## [Security] By default Authelia will:
+  ##   - force all SMTP connections over TLS including unauthenticated connections
+  ##      - use the disable_require_tls boolean value to disable this requirement
+  ##        (only works for unauthenticated connections)
+  ##   - validate the SMTP server x509 certificate during the TLS handshake against the hosts trusted certificates
+  ##     (configure in tls section)
+  # smtp:
+    ## The address of the SMTP server to connect to in the address common syntax.
+    # address: 'smtp://127.0.0.1:25'
+
+    ## The connection timeout in the duration common syntax.
+    # timeout: '5 seconds'
+
+    ## The username used for SMTP authentication.
+    # username: 'test'
+
+    ## The password used for SMTP authentication.
+    ## Can also be set using a secret: https://www.authelia.com/c/secrets
+    # password: 'password'
+
+    ## The sender is used to is used for the MAIL FROM command and the FROM header.
+    ## If this is not defined and the username is an email, we use the username as this value. This can either be just
+    ## an email address or the RFC5322 'Name <email address>' format.
+    # sender: 'Authelia <admin@example.com>'
+
+    ## HELO/EHLO Identifier. Some SMTP Servers may reject the default of localhost.
+    # identifier: 'localhost'
+
+    ## Subject configuration of the emails sent. {title} is replaced by the text from the notifier.
+    # subject: '[Authelia] {title}'
+
+    ## This address is used during the startup check to verify the email configuration is correct.
+    ## It's not important what it is except if your email server only allows local delivery.
+    # startup_check_address: 'test@authelia.com'
+
+    ## By default we require some form of TLS. This disables this check though is not advised.
+    # disable_require_tls: false
+
+    ## Disables sending HTML formatted emails.
+    # disable_html_emails: false
+
+    # tls:
+      ## The server subject name to check the servers certificate against during the validation process.
+      ## This option is not required if the certificate has a SAN which matches the address options hostname.
+      # server_name: 'smtp.example.com'
+
+      ## Skip verifying the server certificate entirely. In preference to setting this we strongly recommend you add the
+      ## certificate or the certificate of the authority signing the certificate to the certificates directory which is
+      ## defined by the `certificates_directory` option at the top of the configuration.
+      ## It's important to note the public key should be added to the directory, not the private key.
+      ## This option is strongly discouraged but may be useful in some self-signed situations where validation is not
+      ## important to the administrator.
+      # skip_verify: false
+
+      ## Minimum TLS version for the connection.
+      # minimum_version: 'TLS1.2'
+
+      ## Maximum TLS version for the connection.
+      # maximum_version: 'TLS1.3'
+
+      ## The certificate chain used with the private_key if the server requests TLS Client Authentication
+      ## i.e. Mutual TLS.
+      # certificate_chain: |
+        # -----BEGIN CERTIFICATE-----
+        # ...
+        # -----END CERTIFICATE-----
+        # -----BEGIN CERTIFICATE-----
+        # ...
+        # -----END CERTIFICATE-----
+
+      ## The private key used with the certificate_chain if the server requests TLS Client Authentication
+      ## i.e. Mutual TLS.
+      # private_key: |
+        # -----BEGIN RSA PRIVATE KEY-----
+        # ...
+        # -----END RSA PRIVATE KEY-----
+
+##
+## Identity Providers
+##
+identity_providers:
+
+  ##
+  ## OpenID Connect (Identity Provider)
+  ##
+  ## It's recommended you read the documentation before configuration of this section:
+  ## https://www.authelia.com/c/oidc
+  oidc:
+    ## The hmac_secret is used to sign OAuth2 tokens (authorization code, access tokens and refresh tokens).
+    ## HMAC Secret can also be set using a secret: https://www.authelia.com/c/secrets
+    hmac_secret: {{ secret (mustEnv "HMAC_SECRET_FILE") }}
+
+    ## The JWK's issuer option configures multiple JSON Web Keys. It's required that at least one of the JWK's
+    ## configured has the RS256 algorithm. For RSA keys (RS or PS) the minimum is a 2048 bit key.
+    jwks:
+    -
+      ## Key ID embedded into the JWT header for key matching. Must be an alphanumeric string with 7 or less characters.
+      ## This value is automatically generated if not provided. It's recommended to not configure this.
+      # key_id: 'example'
+
+      ## The key algorithm used with this key.
+      algorithm: 'RS256'
+
+      ## The key use expected with this key. Currently only 'sig' is supported.
+      use: 'sig'
+
+      ## Required Private Key in PEM DER form.
+      key: {{ secret (mustEnv "JWT_PRIVATE_KEY_FILE") | mindent 10 "|" | msquote }}
+
+
+      ## Optional matching certificate chain in PEM DER form that matches the key. All certificates within the chain
+      ## must be valid and current, and from top to bottom each certificate must be signed by the subsequent one.
+      # certificate_chain: |
+        # -----BEGIN CERTIFICATE-----
+        # ...
+        # -----END CERTIFICATE-----
+        # -----BEGIN CERTIFICATE-----
+        # ...
+        # -----END CERTIFICATE-----
+
+    ## Enables additional debug messages.
+    # enable_client_debug_messages: false
+
+    ## SECURITY NOTICE: It's not recommended changing this option and values below 8 are strongly discouraged.
+    # minimum_parameter_entropy: 8
+
+    ## SECURITY NOTICE: It's not recommended changing this option, and highly discouraged to have it set to 'never'
+    ## for security reasons.
+    # enforce_pkce: 'public_clients_only'
+
+    ## SECURITY NOTICE: It's not recommended changing this option. We encourage you to read the documentation and fully
+    ## understanding it before enabling this option.
+    # enable_jwt_access_token_stateless_introspection: false
+
+    ## The signing algorithm used for signing the discovery and metadata responses. An issuer JWK with a matching
+    ## algorithm must be available when configured. Most clients completely ignore this and it has a performance cost.
+    # discovery_signed_response_alg: 'none'
+
+    ## The signing key id used for signing the discovery and metadata responses. An issuer JWK with a matching key id
+    ## must be available when configured. Most clients completely ignore this and it has a performance cost.
+    # discovery_signed_response_key_id: ''
+
+    ## Authorization Policies which can be utilized by clients. The 'policy_name' is an arbitrary value that you pick
+    ## which is utilized as the value for the 'authorization_policy' on the client.
+    # authorization_policies:
+      # policy_name:
+        # default_policy: 'two_factor'
+        # rules:
+          # - policy: 'one_factor'
+          #   subject: 'group:services'
+
+    ## The lifespans configure the expiration for these token types in the duration common syntax. In addition to this
+    ## syntax the lifespans can be customized per-client.
+    # lifespans:
+      ## Configures the default/fallback lifespan for given token types. This behaviour applies to all clients and all
+      ## grant types but you can override this behaviour using the custom lifespans.
+      # access_token: '1 hour'
+      # authorize_code: '1 minute'
+      # id_token: '1 hour'
+      # refresh_token: '90 minutes'
+
+    ## Cross-Origin Resource Sharing (CORS) settings.
+    # cors:
+      ## List of endpoints in addition to the metadata endpoints to permit cross-origin requests on.
+      # endpoints:
+        #  - 'authorization'
+        #  - 'pushed-authorization-request'
+        #  - 'token'
+        #  - 'revocation'
+        #  - 'introspection'
+        #  - 'userinfo'
+
+      ## List of allowed origins.
+      ## Any origin with https is permitted unless this option is configured or the
+      ## allowed_origins_from_client_redirect_uris option is enabled.
+      # allowed_origins:
+        # - 'https://example.com'
+
+      ## Automatically adds the origin portion of all redirect URI's on all clients to the list of allowed_origins,
+      ## provided they have the scheme http or https and do not have the hostname of localhost.
+      # allowed_origins_from_client_redirect_uris: false
+
+    ## Clients is a list of known clients and their configuration.
+    clients:
+      -
+        ## The Client ID is the OAuth 2.0 and OpenID Connect 1.0 Client ID which is used to link an application to a
+        ## configuration.
+        client_id: 'grist-local'
+
+        ## The description to show to users when they end up on the consent screen. Defaults to the ID above.
+        client_name: 'Grist'
+
+        ## The client secret is a shared secret between Authelia and the consumer of this client.
+        # yamllint disable-line rule:line-length
+        client_secret: {{ secret (mustEnv "GRIST_CLIENT_SECRET_DIGEST_FILE") }}
+
+        ## Sector Identifiers are occasionally used to generate pairwise subject identifiers. In most cases this is not
+        ## necessary. It is critical to read the documentation for more information.
+        # sector_identifier_uri: 'https://example.com/sector.json'
+
+        ## Sets the client to public. This should typically not be set, please see the documentation for usage.
+        # public: false
+
+        ## Redirect URI's specifies a list of valid case-sensitive callbacks for this client.
+        redirect_uris:
+          - {{ mustEnv "GRIST_OAUTH_CALLBACK_URL" | quote }}
+
+        ## Request URI's specifies a list of valid case-sensitive TLS-secured URIs for this client for use as
+        ## URIs to fetch Request Objects.
+        # request_uris:
+          # - 'https://oidc.example.com:8080/oidc/request-object.jwk'
+
+        ## Audience this client is allowed to request.
+        # audience: []
+
+        ## Scopes this client is allowed to request.
+        scopes:
+          - 'openid'
+          - 'groups'
+          - 'email'
+          - 'profile'
+
+        ## Grant Types configures which grants this client can obtain.
+        ## It's not recommended to define this unless you know what you're doing.
+        # grant_types:
+          # - 'authorization_code'
+
+        ## Response Types configures which responses this client can be sent.
+        ## It's not recommended to define this unless you know what you're doing.
+        # response_types:
+          # - 'code'
+
+        ## Response Modes configures which response modes this client supports.
+        # response_modes:
+          # - 'form_post'
+          # - 'query'
+
+        ## The policy to require for this client; one_factor or two_factor. Can also be the key names for the
+        ## authorization policies section.
+        authorization_policy: 'one_factor'
+
+        ## The custom lifespan name to use for this client. This must be configured independent of the client before
+        ## utilization. Custom lifespans are reusable similar to authorization policies.
+        # lifespan: ''
+
+        ## The consent mode controls how consent is obtained.
+        # consent_mode: 'auto'
+
+        ## This value controls the duration a consent on this client remains remembered when the consent mode is
+        ## configured as 'auto' or 'pre-configured' in the duration common syntax.
+        # pre_configured_consent_duration: '1 week'
+
+        ## Requires the use of Pushed Authorization Requests for this client when set to true.
+        # require_pushed_authorization_requests: false
+
+        ## Enforces the use of PKCE for this client when set to true.
+        # require_pkce: false
+
+        ## Enforces the use of PKCE for this client when configured, and enforces the specified challenge method.
+        ## Options are 'plain' and 'S256'.
+        # pkce_challenge_method: 'S256'
+
+        ## The permitted client authentication method for the Token Endpoint for this client.
+        ## For confidential client types this value defaults to 'client_secret_basic' and for the public client types it
+        ## defaults to 'none' per the specifications.
+        # token_endpoint_auth_method: 'client_secret_basic'
+
+        ## The permitted client authentication signing algorithm for the Token Endpoint for this client when using
+        ## the 'client_secret_jwt' or 'private_key_jwt' token_endpoint_auth_method.
+        # token_endpoint_auth_signing_alg: 'RS256'
+
+        ## The signing algorithm which must be used for request objects. A client JWK with a matching algorithm must be
+        ## available if configured.
+        # request_object_signing_alg: 'RS256'
+
+        ## The signing algorithm used for signing the authorization response. An issuer JWK with a matching algorithm
+        ## must be available when configured. Configuring this value enables the  JWT Secured Authorization Response
+        ## Mode (JARM) for this client. JARM is not understood by a majority of clients so you should only configure
+        ## this when you know it's supported.
+        ## Has no effect if authorization_signed_response_key_id is configured.
+        # authorization_signed_response_alg: 'none'
+
+        ## The signing key id used for signing the authorization response. An issuer JWK with a matching key id must be
+        ## available when configured. Configuring this value enables the JWT Secured Authorization Response Mode (JARM)
+        ## for this client. JARM is not understood by a majority of clients so you should only configure this when you
+        ## know it's supported.
+        # authorization_signed_response_key_id: ''
+
+        ## The signing algorithm used for ID Tokens. An issuer JWK with a matching algorithm must be available when
+        ## configured. Has no effect if id_token_signed_response_key_id is configured.
+        # id_token_signed_response_alg: 'RS256'
+
+        ## The signing key id used for ID Tokens. An issuer JWK with a matching key id must be available when
+        ## configured.
+        # id_token_signed_response_key_id: ''
+
+        ## The signing algorithm used for Access Tokens. An issuer JWK with a matching algorithm must be available.
+        ## Has no effect if access_token_signed_response_key_id is configured. Values other than 'none' enable RFC9068
+        ## for this client.
+        # access_token_signed_response_alg: 'none'
+
+        ## The signing key id used for Access Tokens. An issuer JWK with a matching key id must be available when
+        ## configured. Values other than a blank value enable RFC9068 for this client.
+        # access_token_signed_response_key_id: ''
+
+        ## The signing algorithm used for User Info responses. An issuer JWK with a matching algorithm must be
+        ## available. Has no effect if userinfo_signing_key_id is configured.
+        # userinfo_signed_response_alg: 'none'
+
+        ## The signing key id used for User Info responses. An issuer JWK with a matching key id must be available when
+        ## configured.
+        # userinfo_signed_response_key_id: ''
+
+        ## The signing algorithm used for Introspection responses. An issuer JWK with a matching algorithm must be
+        ## available when configured. Has no effect if introspection_signed_response_key_id is configured.
+        # introspection_signed_response_alg: 'none'
+
+        ## The signing key id used for Introspection responses. An issuer JWK with a matching key id must be available
+        ## when configured.
+        # introspection_signed_response_key_id: ''
+
+        ## Trusted public keys configuration for request object signing for things such as 'private_key_jwt'.
+        ## URL of the HTTPS endpoint which serves the keys. Please note the 'jwks_uri' and the 'jwks' option below
+        ## are mutually exclusive.
+        # jwks_uri: 'https://app.example.com/jwks.json'
+
+        ## Trusted public keys configuration for request object signing for things such as 'private_key_jwt'.
+        ## List of JWKs known and registered with this client. It's recommended to use the 'jwks_uri' option if
+        ## available due to key rotation. Please note the 'jwks' and the 'jwks_uri' option above are mutually exclusive.
+        # jwks:
+          # -
+            ## Key ID used to match the JWT's to an individual identifier. This option is required if configured.
+            # key_id: 'example'
+
+            ## The key algorithm expected with this key.
+            # algorithm: 'RS256'
+
+            ## The key use expected with this key. Currently only 'sig' is supported.
+            # use: 'sig'
+
+            ## Required Public Key in PEM DER form.
+            # key: |
+              # -----BEGIN RSA PUBLIC KEY-----
+              # ...
+              # -----END RSA PUBLIC KEY-----
+
+            ## The matching certificate chain in PEM DER form that matches the key if available.
+            # certificate_chain: |
+              # -----BEGIN CERTIFICATE-----
+              # ...
+              # -----END CERTIFICATE-----
+              # -----BEGIN CERTIFICATE-----
+              # ...
+              # -----END CERTIFICATE-----
+...
diff --git a/docker-compose-examples/grist-traefik-oidc-auth/configs/authelia/users_database.yml b/docker-compose-examples/grist-traefik-oidc-auth/configs/authelia/users_database.yml
new file mode 100644
index 00000000..172a106f
--- /dev/null
+++ b/docker-compose-examples/grist-traefik-oidc-auth/configs/authelia/users_database.yml
@@ -0,0 +1,14 @@
+# Primary users file.
+
+# Passwords are generated using 'authelia crypto hash generate argon2'
+# E.g:
+#     docker run authelia/authelia:4 authelia crypto hash generate argon2 --password "test"
+# See https://www.authelia.com/reference/guides/passwords/#yaml-format
+
+users:
+  test:
+    disabled: false
+    displayname: 'Test'
+    password: '$argon2id$v=19$m=65536,t=3,p=4$j1Jub3z0jWBmXNOjNpRK5w$d5176FINCAuzdT3uehQqMS08FC4fadAGrqyZL+0W+p4'
+    email: 'test@example.org'
+    groups: []
diff --git a/docker-compose-examples/grist-traefik-oidc-auth/configs/traefik/config.yml b/docker-compose-examples/grist-traefik-oidc-auth/configs/traefik/config.yml
new file mode 100644
index 00000000..5067ebff
--- /dev/null
+++ b/docker-compose-examples/grist-traefik-oidc-auth/configs/traefik/config.yml
@@ -0,0 +1,30 @@
+providers:
+  # Enables reading docker label config values
+  docker: {}
+
+entrypoints:
+  # Defines a secure entrypoint using TLS encryption
+  websecure:
+    address: ":443"
+    http:
+      tls: true
+  # Defines an insecure entrypoint that redirects to the secure one.
+  web:
+    address: ":80"
+    http:
+      # Redirects HTTP to HTTPS
+      redirections:
+        entrypoint:
+          to: "websecure"
+          scheme: "https"
+
+# Enables automatic certificate renewal
+certificatesResolvers:
+  letsencrypt:
+    acme:
+      email: "my_email@example.com"
+      storage: /acme/acme.json
+      tlschallenge: true
+
+api:
+  insecure: true
diff --git a/docker-compose-examples/grist-traefik-oidc-auth/docker-compose.yml b/docker-compose-examples/grist-traefik-oidc-auth/docker-compose.yml
new file mode 100644
index 00000000..2c8b8ee9
--- /dev/null
+++ b/docker-compose-examples/grist-traefik-oidc-auth/docker-compose.yml
@@ -0,0 +1,118 @@
+secrets:
+  # These secrets are used by Authelia
+  JWT_SECRET:
+    file: ${SECRETS_DIR}/JWT_SECRET
+  SESSION_SECRET:
+    file: ${SECRETS_DIR}/SESSION_SECRET
+  STORAGE_ENCRYPTION_KEY:
+    file: ${SECRETS_DIR}/STORAGE_ENCRYPTION_KEY
+  # These secrets are for using Authelia as an OIDC provider
+  HMAC_SECRET:
+    file: ${SECRETS_DIR}/HMAC_SECRET
+  JWT_PRIVATE_KEY:
+    file: ${SECRETS_DIR}/certs/private.pem
+  GRIST_CLIENT_SECRET_DIGEST:
+    file: ${SECRETS_DIR}/GRIST_CLIENT_SECRET_DIGEST
+
+services:
+  grist:
+    image: gristlabs/grist:latest
+    environment:
+      # The URL of given OIDC provider. Used for redirects, among other things.
+      GRIST_OIDC_IDP_ISSUER: https://${AUTHELIA_DOMAIN}
+      # Client ID, as configured with the OIDC provider.
+      GRIST_OIDC_IDP_CLIENT_ID: grist-local
+      # Client secret, as provided by the OIDC provider.
+      GRIST_OIDC_IDP_CLIENT_SECRET: ${GRIST_CLIENT_SECRET}
+      # The URL to redirect to with the OIDC provider to log out.
+      # Some OIDC providers will automatically configure this.
+      GRIST_OIDC_IDP_END_SESSION_ENDPOINT: https://${AUTHELIA_DOMAIN}/logout
+      # Allow self-signed certificates so this example behaves correctly.
+      # REMOVE THIS IF HOSTING ON THE INTERNET.
+      NODE_TLS_REJECT_UNAUTHORIZED: 0
+
+      # Forces Grist to only use a single team called 'Example'
+      GRIST_SINGLE_ORG: my-grist-team   # alternatively, GRIST_ORG_IN_PATH: "true" for multi-team operation
+      # Force users to login (disable anonymous access)
+      GRIST_FORCE_LOGIN: true
+      # Base URL Grist redirects to when navigating. Change this to your domain.
+      APP_HOME_URL: https://${GRIST_DOMAIN}
+      # Default email for the "Admin" account
+      GRIST_DEFAULT_EMAIL: ${DEFAULT_EMAIL:-test@example.org}
+    restart: always
+    volumes:
+      # Where to store persistent data, such as documents.
+      - ${PERSIST_DIR}/grist:/persist
+    labels:
+      - "traefik.http.services.grist.loadbalancer.server.port=8484"
+      - "traefik.http.routers.grist.rule=Host(`${GRIST_DOMAIN}`)"
+      - "traefik.http.routers.grist.service=grist"
+      # Uncomment and configure in traefik-config.yml to enable automatic HTTPS certificate setup.
+      #- "traefik.http.routers.grist.tls.certresolver=letsencrypt"
+    depends_on:
+      # Grist attempts to setup OIDC when it starts, making a request to the OIDC service.
+      # This will fail if Authelia isn't ready and reachable.
+      # Traefik will only start routing to Authelia when it's registered as healthy.
+      # Making Grist wait for Authelia to be healthy should avoid this issue.
+      authelia:
+        condition: service_healthy
+      traefik:
+        condition: service_started
+
+  traefik:
+    image: traefik:latest
+    ports:
+      # HTTP Ports
+      - "80:80"
+      - "443:443"
+      # The Web UI (enabled by --api.insecure=true)
+      - "8080:8080"
+      - "8082:8082"
+    volumes:
+      # Set the config file for traefik - this is loaded automatically.
+      - ./configs/traefik/config.yml:/etc/traefik/traefik.yml
+      # Certificate location, if automatic certificate setup is enabled.
+      - ./secrets/acme_certificates:/acme
+      # Traefik needs docker access when configured via docker labels.
+      - /var/run/docker.sock:/var/run/docker.sock
+    networks:
+      default:
+        aliases:
+          # Enables Grist to resolve this domain to Traefik when doing OIDC setup.
+          - ${AUTHELIA_DOMAIN}
+
+  authelia:
+    image: authelia/authelia:4
+    secrets:
+      - HMAC_SECRET
+      - JWT_SECRET
+      - JWT_PRIVATE_KEY
+      - GRIST_CLIENT_SECRET_DIGEST
+      - SESSION_SECRET
+      - STORAGE_ENCRYPTION_KEY
+    environment:
+      AUTHELIA_IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET_FILE: '/run/secrets/JWT_SECRET'
+      AUTHELIA_SESSION_SECRET_FILE: '/run/secrets/SESSION_SECRET'
+      AUTHELIA_STORAGE_ENCRYPTION_KEY_FILE: '/run/secrets/STORAGE_ENCRYPTION_KEY'
+      HMAC_SECRET_FILE: '/run/secrets/HMAC_SECRET'
+      JWT_PRIVATE_KEY_FILE: '/run/secrets/JWT_PRIVATE_KEY'
+      # Domain Grist is hosted at. Custom variable that's interpolated into the Authelia config
+      APP_DOMAIN: ${GRIST_DOMAIN}
+      # Where Authelia should redirect to after successful authentication.
+      GRIST_OAUTH_CALLBACK_URL: https://${GRIST_DOMAIN}/oauth2/callback
+      # Hash of the client secret provided to Grist.
+      GRIST_CLIENT_SECRET_DIGEST_FILE: "/run/secrets/GRIST_CLIENT_SECRET_DIGEST"
+    volumes:
+      - ./configs/authelia:/config
+      - ${PERSIST_DIR}/authelia:/persist
+    command:
+      - 'authelia'
+      - '--config=/config/configuration.yml'
+      # Enables templating in the config file
+      - '--config.experimental.filters=template'
+    labels:
+      - "traefik.http.services.authelia.loadbalancer.server.port=9091"
+      - "traefik.http.routers.authelia.rule=Host(`${AUTHELIA_DOMAIN}`)"
+      - "traefik.http.routers.authelia.service=authelia"
+      # Uncomment and configure in traefik-config.yml to enable automatic HTTPS certificate setup.
+      #- "traefik.http.routers.authelia.tls.certresolver=letsencrypt"
diff --git a/docker-compose-examples/grist-traefik-oidc-auth/env-template b/docker-compose-examples/grist-traefik-oidc-auth/env-template
new file mode 100644
index 00000000..e9e0bd79
--- /dev/null
+++ b/docker-compose-examples/grist-traefik-oidc-auth/env-template
@@ -0,0 +1,6 @@
+GRIST_DOMAIN=grist.localhost
+AUTHELIA_DOMAIN=auth.grist.localhost
+DEFAULT_EMAIL=test@example.org
+PERSIST_DIR=./persist
+SECRETS_DIR=./secrets
+GRIST_CLIENT_SECRET=
diff --git a/docker-compose-examples/grist-traefik-oidc-auth/generateSecureSecrets.sh b/docker-compose-examples/grist-traefik-oidc-auth/generateSecureSecrets.sh
new file mode 100755
index 00000000..7c6a29ad
--- /dev/null
+++ b/docker-compose-examples/grist-traefik-oidc-auth/generateSecureSecrets.sh
@@ -0,0 +1,32 @@
+# Helper script to securely generate random secrets for Authelia.
+
+SCRIPT_DIR=$(dirname $0)
+
+# Copy over template files to final locations
+cp -R "$SCRIPT_DIR/secrets_template" "$SCRIPT_DIR/secrets"
+cp "$SCRIPT_DIR/env-template" "$SCRIPT_DIR/.env"
+
+# Parses an Aurelia generated secret for the value
+function getSecret {
+  cut -d ":" -f 2 <<< "$1" | tr -d '[:blank:]'
+}
+
+function generateSecureString {
+  getSecret "$(docker run authelia/authelia:4 authelia crypto rand --charset=rfc3986 --length="$1")"
+}
+
+generateSecureString 128 > "$SCRIPT_DIR/secrets/HMAC_SECRET"
+generateSecureString 128 > "$SCRIPT_DIR/secrets/JWT_SECRET"
+generateSecureString 128 > "$SCRIPT_DIR/secrets/SESSION_SECRET"
+generateSecureString 128 > "$SCRIPT_DIR/secrets/STORAGE_ENCRYPTION_KEY"
+
+# Generates the OIDC secret key for the Grist client
+CLIENT_SECRET_OUTPUT="$(docker run authelia/authelia:4 authelia crypto hash generate pbkdf2 --variant sha512 --random --random.length 72 --random.charset rfc3986)"
+CLIENT_SECRET=$(getSecret "$(grep 'Password' <<< $CLIENT_SECRET_OUTPUT)")
+sed -i "/GRIST_CLIENT_SECRET=$/d" "$SCRIPT_DIR/.env"
+echo "GRIST_CLIENT_SECRET=$CLIENT_SECRET" >> "$SCRIPT_DIR/.env"
+getSecret "$(grep 'Digest' <<< $CLIENT_SECRET_OUTPUT)" >> "$SCRIPT_DIR/secrets/GRIST_CLIENT_SECRET_DIGEST"
+
+# Generate JWT certificates Authelia needs for OIDC
+docker run -v ./secrets/certs:/certs authelia/authelia:4 authelia crypto certificate rsa generate -d /certs
+
diff --git a/docker-compose-examples/grist-traefik-oidc-auth/secrets_template/GRIST_CLIENT_SECRET_DIGEST b/docker-compose-examples/grist-traefik-oidc-auth/secrets_template/GRIST_CLIENT_SECRET_DIGEST
new file mode 100644
index 00000000..e69de29b
diff --git a/docker-compose-examples/grist-traefik-oidc-auth/secrets_template/HMAC_SECRET b/docker-compose-examples/grist-traefik-oidc-auth/secrets_template/HMAC_SECRET
new file mode 100644
index 00000000..e69de29b
diff --git a/docker-compose-examples/grist-traefik-oidc-auth/secrets_template/JWT_SECRET b/docker-compose-examples/grist-traefik-oidc-auth/secrets_template/JWT_SECRET
new file mode 100644
index 00000000..e69de29b
diff --git a/docker-compose-examples/grist-traefik-oidc-auth/secrets_template/SESSION_SECRET b/docker-compose-examples/grist-traefik-oidc-auth/secrets_template/SESSION_SECRET
new file mode 100644
index 00000000..e69de29b
diff --git a/docker-compose-examples/grist-traefik-oidc-auth/secrets_template/STORAGE_ENCRYPTION_KEY b/docker-compose-examples/grist-traefik-oidc-auth/secrets_template/STORAGE_ENCRYPTION_KEY
new file mode 100644
index 00000000..e69de29b
diff --git a/docker-compose-examples/grist-traefik-oidc-auth/secrets_template/certs/private.pem b/docker-compose-examples/grist-traefik-oidc-auth/secrets_template/certs/private.pem
new file mode 100644
index 00000000..e69de29b
diff --git a/docker-compose-examples/grist-with-postgres-redis-minio/.env b/docker-compose-examples/grist-with-postgres-redis-minio/.env
new file mode 100644
index 00000000..1ade28c8
--- /dev/null
+++ b/docker-compose-examples/grist-with-postgres-redis-minio/.env
@@ -0,0 +1,3 @@
+DATABASE_PASSWORD=CHANGE THIS PASSWORD
+MINIO_PASSWORD=CHANGE THIS PASSWORD
+PERSIST_DIR=./persist
diff --git a/docker-compose-examples/grist-with-postgres-redis-minio/README.md b/docker-compose-examples/grist-with-postgres-redis-minio/README.md
new file mode 100644
index 00000000..517d1716
--- /dev/null
+++ b/docker-compose-examples/grist-with-postgres-redis-minio/README.md
@@ -0,0 +1,20 @@
+This examples shows how to start up Grist that:
+- Uses Postgres as a home database,
+- Redis as a state store.
+- MinIO for snapshot storage
+
+It is STRONGLY RECOMMENDED not to use this container in a way that makes it accessible to the internet.
+This setup lacks basic security or authentication.
+
+Other examples demonstrate how to set up authentication and HTTPS.
+
+See https://support.getgrist.com/self-managed for more information.
+
+This setup is based on one provided by Akito (https://github.com/theAkito).
+
+## How to run this example
+
+Before running this example, it's very strongly recommended to update the `_PASSWORD` environment variables
+in `.env` to be long, randomly generated passwords.
+
+This example can be run with `docker compose up`.
diff --git a/docker-compose-examples/grist-with-postgres-redis-minio/docker-compose.yml b/docker-compose-examples/grist-with-postgres-redis-minio/docker-compose.yml
new file mode 100644
index 00000000..1202fd27
--- /dev/null
+++ b/docker-compose-examples/grist-with-postgres-redis-minio/docker-compose.yml
@@ -0,0 +1,76 @@
+services:
+  grist:
+    image: gristlabs/grist:latest
+    environment:
+      # Postgres database setup
+      TYPEORM_DATABASE: grist
+      TYPEORM_USERNAME: grist
+      TYPEORM_HOST: grist-db
+      TYPEORM_LOGGING: false
+      TYPEORM_PASSWORD: ${DATABASE_PASSWORD}
+      TYPEORM_PORT: 5432
+      TYPEORM_TYPE: postgres
+
+      # Redis setup
+      REDIS_URL: redis://grist-redis
+
+      # MinIO setup. This requires the bucket set up on the MinIO instance with versioning enabled.
+      GRIST_DOCS_MINIO_ACCESS_KEY: grist
+      GRIST_DOCS_MINIO_SECRET_KEY: ${MINIO_PASSWORD}
+      GRIST_DOCS_MINIO_USE_SSL: 0
+      GRIST_DOCS_MINIO_BUCKET: grist-docs
+      GRIST_DOCS_MINIO_ENDPOINT: grist-minio
+      GRIST_DOCS_MINIO_PORT: 9000
+
+    volumes:
+      # Where to store persistent data, such as documents.
+      - ${PERSIST_DIR}/grist:/persist
+    ports:
+      - 8484:8484
+    depends_on:
+      - grist-db
+      - grist-redis
+      - grist-minio
+      - minio-setup
+
+  grist-db:
+    image: postgres:alpine
+    environment:
+        POSTGRES_DB: grist
+        POSTGRES_USER: grist
+        POSTGRES_PASSWORD: ${DATABASE_PASSWORD}
+    volumes:
+      - ${PERSIST_DIR}/postgres:/var/lib/postgresql/data
+
+  grist-redis:
+    image: redis:alpine
+    volumes:
+      - ${PERSIST_DIR}/redis:/data
+
+  grist-minio:
+    image: minio/minio:latest
+    environment:
+      MINIO_ROOT_USER: grist
+      MINIO_ROOT_PASSWORD: ${MINIO_PASSWORD}
+    volumes:
+      - ${PERSIST_DIR}/minio:/data
+    command:
+      server /data --console-address=":9001"
+
+  # This sets up the buckets required in MinIO. It is only needed to make this example work.
+  # It isn't necessary for deployment and can be safely removed.
+  minio-setup:
+    image: minio/mc
+    environment:
+      MINIO_PASSWORD: ${MINIO_PASSWORD}
+    depends_on:
+      grist-minio:
+        condition: service_started
+    restart: on-failure
+    entrypoint: >
+      /bin/sh -c "
+      /usr/bin/mc alias set myminio http://grist-minio:9000 grist '$MINIO_PASSWORD';
+      /usr/bin/mc mb myminio/grist-docs;
+      /usr/bin/mc anonymous set public myminio/grist-docs;
+      /usr/bin/mc version enable myminio/grist-docs;
+      "

From 5ef54b278fda1fa122e27062d763d94f7d785064 Mon Sep 17 00:00:00 2001
From: Dmitry S <dsagal+git@gmail.com>
Date: Tue, 13 Aug 2024 09:48:54 -0400
Subject: [PATCH 126/145] (core) When getting error details for on-demand
 formulas, provide an explanation

Summary:
Since formula errors are typically obtained from the Python data engine, they
were not returning any info for errors in on-demand tables (not loaded into the
data engine). This change implements a detailed message to explain such errors,
mainly to point out that on-demand table is the reason.

Test Plan: Added a check to the OnDemand test that formula error details are shown.

Reviewers: jarek

Reviewed By: jarek

Differential Revision: https://phab.getgrist.com/D4317
---
 app/client/widgets/FormulaEditor.ts |  1 +
 app/server/lib/ActiveDoc.ts         |  7 ++++++-
 app/server/lib/ExpandedQuery.ts     | 19 +++++++++++++++++++
 3 files changed, 26 insertions(+), 1 deletion(-)

diff --git a/app/client/widgets/FormulaEditor.ts b/app/client/widgets/FormulaEditor.ts
index 010c1e93..80ea836b 100644
--- a/app/client/widgets/FormulaEditor.ts
+++ b/app/client/widgets/FormulaEditor.ts
@@ -480,6 +480,7 @@ function _isInIdentifier(line: string, column: number) {
 
 /**
  * Open a formula editor. Returns a Disposable that owns the editor.
+ * This is used for the editor in the side panel.
  */
 export function openFormulaEditor(options: {
   gristDoc: GristDoc,
diff --git a/app/server/lib/ActiveDoc.ts b/app/server/lib/ActiveDoc.ts
index e4e9d7a0..14df8946 100644
--- a/app/server/lib/ActiveDoc.ts
+++ b/app/server/lib/ActiveDoc.ts
@@ -138,7 +138,7 @@ import {
   OptDocSession
 } from './DocSession';
 import {createAttachmentsIndex, DocStorage, REMOVE_UNUSED_ATTACHMENTS_DELAY} from './DocStorage';
-import {expandQuery} from './ExpandedQuery';
+import {expandQuery, getFormulaErrorForExpandQuery} from './ExpandedQuery';
 import {GranularAccess, GranularAccessForBundle} from './GranularAccess';
 import {OnDemandActions} from './OnDemandActions';
 import {getLogMetaFromDocSession, getPubSubPrefix, getTelemetryMetaFromDocSession} from './serverUtils';
@@ -1169,6 +1169,11 @@ export class ActiveDoc extends EventEmitter {
     this._log.info(docSession, "getFormulaError(%s, %s, %s, %s)",
       docSession, tableId, colId, rowId);
     await this.waitForInitialization();
+    const onDemand = this._onDemandActions.isOnDemand(tableId);
+    if (onDemand) {
+      // It's safe to use this.docData after waitForInitialization().
+      return getFormulaErrorForExpandQuery(this.docData!, tableId, colId);
+    }
     return this._pyCall('get_formula_error', tableId, colId, rowId);
   }
 
diff --git a/app/server/lib/ExpandedQuery.ts b/app/server/lib/ExpandedQuery.ts
index a128c3a9..3b49cbd0 100644
--- a/app/server/lib/ExpandedQuery.ts
+++ b/app/server/lib/ExpandedQuery.ts
@@ -1,5 +1,6 @@
 import { ServerQuery } from 'app/common/ActiveDocAPI';
 import { ApiError } from 'app/common/ApiError';
+import { CellValue } from 'app/common/DocActions';
 import { DocData } from 'app/common/DocData';
 import { parseFormula } from 'app/common/Formula';
 import { removePrefix } from 'app/common/gutil';
@@ -133,6 +134,24 @@ export function expandQuery(iquery: ServerQuery, docData: DocData, onDemandFormu
   return query;
 }
 
+export function getFormulaErrorForExpandQuery(docData: DocData, tableId: string, colId: string): CellValue {
+  // On-demand tables may produce several kinds of error messages, e.g. "Formula not supported" or
+  // "Cannot find column". We construct the full query to get the basic message for the requested
+  // column, then tack on the detail, which is fine to be the same for all of them.
+  const iquery: ServerQuery = {tableId, filters: {}};
+  const expanded = expandQuery(iquery, docData, true);
+  const constantValue = expanded.constants?.[colId];
+  if (constantValue?.length === 2) {
+    return [GristObjCode.Exception, constantValue[1],
+`Not supported in on-demand tables.
+
+This table is marked as an on-demand table. Such tables don't support most formulas. \
+For proper formula support, unmark it as on-demand.
+`];
+  }
+  return null;
+}
+
 /**
  * Build a query that relates two homogeneous tables sharing a common set of columns,
  * returning rows that exist in both tables (if they have differences), and rows from

From fbc041811871e5861725deb77d8e507016e3fb09 Mon Sep 17 00:00:00 2001
From: Dmitry S <dsagal+git@gmail.com>
Date: Tue, 13 Aug 2024 09:34:13 -0400
Subject: [PATCH 127/145] (core) Fix CustomView taking up more height than page
 layout gives it.

Summary:
Each view type currently responsible for fitting appropriately within the box
it's given (e.g. deciding which container is scrollable). CustomView wasn't
doing a good job of it, particularly when showing "columns aren't mapped"
message.

Test Plan:
Only CSS affected. Checked manually on FF, Chrome, Safari that CustomViews take
the right amount of space, and scroll well, in 3 situations: not-mapped,
not-configured, and a functional widget.

Reviewers: jarek

Reviewed By: jarek

Subscribers: jarek

Differential Revision: https://phab.getgrist.com/D4316
---
 app/client/components/CustomView.css | 11 ++++++++++-
 1 file changed, 10 insertions(+), 1 deletion(-)

diff --git a/app/client/components/CustomView.css b/app/client/components/CustomView.css
index 64c5d0a7..e847ef35 100644
--- a/app/client/components/CustomView.css
+++ b/app/client/components/CustomView.css
@@ -1,3 +1,13 @@
+/*
+ * Ensure the custom view section fits within its allocated area even if it needs to scroll inside
+ * of it. This is not an issue when it contains an iframe, but .custom_view_no_mapping element
+ * could be taller, but its intrinsic height should not affect the container.
+ */
+.custom_view_container {
+  overflow: auto;
+  flex-basis: 0px;
+}
+
 iframe.custom_view {
   border: none;
   height: 100%;
@@ -12,7 +22,6 @@ iframe.custom_view {
 .custom_view_no_mapping {
   padding: 15px;
   margin: 15px;
-  height: 100%;
   display: flex;
   flex-direction: column;
   align-items: center;

From 93ed1bec5ea3f25fa198c95f3367d7374a0143df Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Gr=C3=A9goire=20Cutzach?= <gregoire@cutzach.com>
Date: Wed, 14 Aug 2024 16:59:06 +0200
Subject: [PATCH 128/145] feat: forms inhibited when summary selected and vice
 versa (#1037)

---
 app/client/ui/PageWidgetPicker.ts | 76 ++++++++++++++++++++++++-------
 test/nbrowser/saveViewSection.ts  | 27 ++++++++++-
 2 files changed, 85 insertions(+), 18 deletions(-)

diff --git a/app/client/ui/PageWidgetPicker.ts b/app/client/ui/PageWidgetPicker.ts
index 61552422..e61757f7 100644
--- a/app/client/ui/PageWidgetPicker.ts
+++ b/app/client/ui/PageWidgetPicker.ts
@@ -95,25 +95,46 @@ export interface IOptions extends ISelectOptions {
   placement?: Popper.Placement;
 }
 
+export interface ICompatibleTypes {
+
+  // true if "New Page" is selected in Page Picker
+  isNewPage: Boolean | undefined;
+
+  // true if can be summarized
+  summarize: Boolean;
+}
+
 const testId = makeTestId('test-wselect-');
 
 // The picker disables some choices that do not make much sense. This function return the list of
 // compatible types given the tableId and whether user is creating a new page or not.
-function getCompatibleTypes(tableId: TableRef, isNewPage: boolean|undefined): IWidgetType[] {
+function getCompatibleTypes(tableId: TableRef,
+                            {isNewPage, summarize}: ICompatibleTypes): IWidgetType[] {
+  let compatibleTypes: Array<IWidgetType> = [];
   if (tableId !== 'New Table') {
-    return ['record', 'single', 'detail', 'chart', 'custom', 'custom.calendar', 'form'];
+    compatibleTypes = ['record', 'single', 'detail', 'chart', 'custom', 'custom.calendar', 'form'];
   } else if (isNewPage) {
     // New view + new table means we'll be switching to the primary view.
-    return ['record', 'form'];
+    compatibleTypes = ['record', 'form'];
   } else {
     // The type 'chart' makes little sense when creating a new table.
-    return ['record', 'single', 'detail', 'form'];
+    compatibleTypes = ['record', 'single', 'detail', 'form'];
   }
+  return summarize ? compatibleTypes.filter((el) => isSummaryCompatible(el)) : compatibleTypes;
+}
+
+// The Picker disables some choices that do not make much sense.
+// This function return a boolean telling if summary can be used with this type.
+function isSummaryCompatible(widgetType: IWidgetType): boolean {
+  const incompatibleTypes: Array<IWidgetType> = ['form'];
+  return !incompatibleTypes.includes(widgetType);
 }
 
 // Whether table and type make for a valid selection whether the user is creating a new page or not.
-function isValidSelection(table: TableRef, type: IWidgetType, isNewPage: boolean|undefined) {
-  return table !== null && getCompatibleTypes(table, isNewPage).includes(type);
+function isValidSelection(table: TableRef,
+                          type: IWidgetType,
+                          {isNewPage, summarize}: ICompatibleTypes) {
+  return table !== null && getCompatibleTypes(table, {isNewPage, summarize}).includes(type);
 }
 
 export type ISaveFunc = (val: IPageWidget) => Promise<any>;
@@ -213,7 +234,13 @@ export function buildPageWidgetPicker(
 
   // whether the current selection is valid
   function isValid() {
-    return isValidSelection(value.table.get(), value.type.get(), options.isNewPage);
+    return isValidSelection(
+      value.table.get(),
+      value.type.get(),
+      {
+        isNewPage: options.isNewPage,
+        summarize: value.summarize.get()
+      });
   }
 
   // Summarizing a table causes the 'Group By' panel to expand on the right. To prevent it from
@@ -299,7 +326,7 @@ export class PageWidgetSelect extends Disposable {
     null;
 
   private _isNewTableDisabled = Computed.create(this, this._value.type, (use, type) => !isValidSelection(
-    'New Table', type, this._options.isNewPage));
+    'New Table', type, {isNewPage: this._options.isNewPage, summarize: use(this._value.summarize)}));
 
   constructor(
     private _value: IWidgetValueObs,
@@ -318,7 +345,9 @@ export class PageWidgetSelect extends Disposable {
           header(t("Select Widget")),
           sectionTypes.map((value) => {
             const widgetInfo = getWidgetTypes(value);
-            const disabled = computed(this._value.table, (use, tid) => this._isTypeDisabled(value, tid));
+            const disabled = computed(this._value.table,
+              (use, tid) => this._isTypeDisabled(value, tid, use(this._value.summarize))
+            );
             return cssEntry(
               dom.autoDispose(disabled),
               cssTypeIcon(widgetInfo.icon),
@@ -355,11 +384,14 @@ export class PageWidgetSelect extends Disposable {
                        cssEntry.cls('-selected', (use) => use(this._value.table) === table.id()),
                        testId('table-label')
               ),
-              cssPivot(
-                cssBigIcon('Pivot'),
-                cssEntry.cls('-selected', (use) => use(this._value.summarize) && use(this._value.table) === table.id()),
-                dom.on('click', (ev, el) => this._selectPivot(table.id(), el as HTMLElement)),
-                testId('pivot'),
+                cssPivot(
+                  cssBigIcon('Pivot'),
+                  cssEntry.cls('-selected', (use) => use(this._value.summarize) &&
+                                                     use(this._value.table) === table.id()
+                  ),
+                  cssEntry.cls('-disabled', (use) => !isSummaryCompatible(use(this._value.type))),
+                  dom.on('click', (ev, el) => this._selectPivot(table.id(), el as HTMLElement)),
+                  testId('pivot'),
               ),
               testId('table'),
             )
@@ -410,7 +442,12 @@ export class PageWidgetSelect extends Disposable {
             // there are no changes.
             this._options.buttonLabel || t("Add to Page"),
             dom.prop('disabled', (use) => !isValidSelection(
-              use(this._value.table), use(this._value.type), this._options.isNewPage)
+              use(this._value.table),
+              use(this._value.type),
+              {
+                isNewPage: this._options.isNewPage,
+                summarize: use(this._value.summarize)
+              })
             ),
             dom.on('click', () => this._onSave().catch(reportError)),
             testId('addBtn'),
@@ -464,11 +501,11 @@ export class PageWidgetSelect extends Disposable {
     this._value.columns.set(newIds);
   }
 
-  private _isTypeDisabled(type: IWidgetType, table: TableRef) {
+  private _isTypeDisabled(type: IWidgetType, table: TableRef, isSummaryOn: boolean) {
     if (table === null) {
       return false;
     }
-    return !getCompatibleTypes(table, this._options.isNewPage).includes(type);
+    return !getCompatibleTypes(table, {isNewPage: this._options.isNewPage, summarize: isSummaryOn}).includes(type);
   }
 
 }
@@ -535,6 +572,7 @@ const cssEntry = styled('div', `
   &-disabled {
     color: ${theme.widgetPickerItemDisabledBg};
     cursor: default;
+    pointer-events: none;
   }
   &-disabled&-selected {
     background-color: inherit;
@@ -578,6 +616,10 @@ const cssBigIcon = styled(icon, `
   width: 24px;
   height: 24px;
   background-color: ${theme.widgetPickerSummaryIcon};
+  .${cssEntry.className}-disabled > & {
+    opacity: 0.25;
+    filter: saturate(0);
+  }
 `);
 
 const cssFooter = styled('div', `
diff --git a/test/nbrowser/saveViewSection.ts b/test/nbrowser/saveViewSection.ts
index a1b678f2..002225c3 100644
--- a/test/nbrowser/saveViewSection.ts
+++ b/test/nbrowser/saveViewSection.ts
@@ -99,7 +99,6 @@ describe("saveViewSection", function() {
     await switchTypeAndAssert('Card');
     await switchTypeAndAssert('Table');
     await switchTypeAndAssert('Chart');
-
   });
 
   it("should work correctly when changing grouped by column", async () => {
@@ -160,4 +159,30 @@ describe("saveViewSection", function() {
     // Check all columns are visible.
     await assertActiveSectionColumns('Test', 'count');
   });
+
+  it("should disable summary when form type is selected", async () => {
+    // select form type
+    await driver.find('.test-dp-add-new').doClick();
+    await driver.find('.test-dp-add-new-page').doClick();
+    await driver.findContent('.test-wselect-type', gu.exactMatch("Form")).doClick();
+
+    // check that summary is disabled
+    assert.ok(await driver.find('.test-wselect-pivot[class*=-disabled]'));
+
+    // close page widget picker
+    await driver.sendKeys(Key.ESCAPE);
+  });
+
+  it("should disable form when summary is selected", async () => {
+    // select table type then select summary for a Table
+    await driver.find('.test-dp-add-new').doClick();
+    await driver.find('.test-dp-add-new-page').doClick();
+    await driver.find('.test-wselect-pivot').doClick();
+
+    // check that form is disabled
+    assert.equal(await driver.find('.test-wselect-type[class*=-disabled]').getText(), "Form");
+
+    // close page widget picker
+    await driver.sendKeys(Key.ESCAPE);
+  });
 });

From 9509b2edcb2f2ca54130bdc5edd7c73b13f84397 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jaros=C5=82aw=20Sadzi=C5=84ski?=
 <jaroslaw.sadzinski@gmail.com>
Date: Wed, 14 Aug 2024 14:43:16 +0200
Subject: [PATCH 129/145] (core) Hiding censored pages and all their leaves

Summary:
Page is now hidden when any of its ancestor (or the page itself)
is censored.

Test Plan: Updated

Reviewers: georgegevoian

Reviewed By: georgegevoian

Differential Revision: https://phab.getgrist.com/D4319
---
 app/client/models/DocModel.ts | 23 ++++++++++++++---------
 test/nbrowser/Pages.ts        | 33 +++++++++++----------------------
 2 files changed, 25 insertions(+), 31 deletions(-)

diff --git a/app/client/models/DocModel.ts b/app/client/models/DocModel.ts
index ad6ad06d..41a586e4 100644
--- a/app/client/models/DocModel.ts
+++ b/app/client/models/DocModel.ts
@@ -223,16 +223,21 @@ export class DocModel {
     this.allPages = ko.computed(() => allPages.all());
     this.menuPages = ko.computed(() => {
       const pagesToShow = this.allPages().filter(p => !p.isSpecial()).sort((a, b) => a.pagePos() - b.pagePos());
-      // Helper to find all children of a page.
-      const children = memoize((page: PageRec) => {
-        const following = pagesToShow.slice(pagesToShow.indexOf(page) + 1);
-        const firstOutside = following.findIndex(p => p.indentation() <= page.indentation());
-        return firstOutside >= 0 ? following.slice(0, firstOutside) : following;
+      const parent = memoize((page: PageRec) => {
+        const myIdentation = page.indentation();
+        if (myIdentation === 0) { return null; }
+        const idx = pagesToShow.indexOf(page);
+        // Find first page starting from before that has lower indentation then mine.
+        const beforeMe = pagesToShow.slice(0, idx).reverse();
+        return beforeMe.find(p => p.indentation() < myIdentation) ?? null;
       });
-      // Helper to test if the page is hidden and all its children are hidden.
-      // In that case, we won't show it at all.
-      const hide = memoize((page: PageRec): boolean => page.isCensored() && children(page).every(p => hide(p)));
-      return pagesToShow.filter(p => !hide(p));
+      const ancestors = memoize((page: PageRec): PageRec[] => {
+        const anc = parent(page);
+        return anc ? [anc, ...ancestors(anc)] : [];
+      });
+      // Helper to test if the page is hidden or is in a hidden branch.
+      const hidden = memoize((page: PageRec): boolean => page.isHidden() || ancestors(page).some(p => p.isHidden()));
+      return pagesToShow.filter(p => !hidden(p));
     });
     this.visibleDocPages = ko.computed(() => this.allPages().filter(p => !p.isHidden()));
 
diff --git a/test/nbrowser/Pages.ts b/test/nbrowser/Pages.ts
index 1e406904..9d1bde63 100644
--- a/test/nbrowser/Pages.ts
+++ b/test/nbrowser/Pages.ts
@@ -38,43 +38,34 @@ describe('Pages', function() {
     assert.deepEqual(await gu.getPageTree(), [
       {
         label: 'Interactions', children: [
-          { label: 'Documents' },
+          {label: 'Documents' },
         ]
       },
       {
         label: 'People', children: [
-          { label: 'User & Leads', children: [{ label: 'Overview' }] },
+          {label: 'User & Leads', children: [
+            {label: 'Overview'}] },
         ]
       },
     ]);
     const revertAcl = await gu.beginAclTran(api, doc.id);
-    // Update ACL, hide People table from all users.
-    await hideTable("People");
+    // Update ACL, hide Overview table from all users.
+    await hideTable("Overview");
     // We will be reloaded, but it's not easy to wait for it, so do the refresh manually.
     await gu.reloadDoc();
     assert.deepEqual(await gu.getPageTree(), [
       {
         label: 'Interactions', children: [
-          { label: 'Documents'},
+          {label: 'Documents'},
         ]
       },
       {
-        label: 'CENSORED', children: [
-          { label: 'User & Leads', children: [{ label: 'Overview' }] },
+        label: 'People', children: [
+          {label: 'User & Leads'},
         ]
       },
     ]);
 
-    // Test that we can't click this page.
-    await driver.findContent('.test-treeview-itemHeader', /CENSORED/).click();
-    await gu.waitForServer();
-    assert.equal(await gu.getSectionTitle(), 'INTERACTIONS');
-
-    // Test that we don't have move handler.
-    assert.isFalse(
-      await driver.findContent('.test-treeview-itemHeaderWrapper', /CENSORED/)
-                  .find('.test-treeview-handle').isPresent()
-    );
 
     // Now hide User_Leads
     await hideTable("User_Leads");
@@ -86,14 +77,12 @@ describe('Pages', function() {
         ]
       },
       {
-        label: 'CENSORED', children: [
-          { label: 'CENSORED', children: [{ label: 'Overview' }] },
-        ]
+        label: 'People'
       },
     ]);
 
-    // Now hide Overview, and test that whole node is hidden.
-    await hideTable("Overview");
+    // Now hide People, and test that whole node is hidden.
+    await hideTable("People");
     await gu.reloadDoc();
     assert.deepEqual(await gu.getPageTree(), [
       {

From a16d76d25db22179fb9d6be1569a6f3bb5ea5549 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= <jordigh@octave.org>
Date: Wed, 14 Aug 2024 13:06:52 -0400
Subject: [PATCH 130/145] (core) config: rename TEST_ENABLE_ACTIVATION to
 GRIST_FORCE_ENABLE_ENTERPRISE

Summary:
The name of this env var has bothered me for a little while.
Let's rename it more meaningfully.

Test Plan: No need to test, cosmetic change only.

Reviewers: jarek

Reviewed By: jarek

Differential Revision: https://phab.getgrist.com/D4320
---
 app/server/lib/configCore.ts | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/app/server/lib/configCore.ts b/app/server/lib/configCore.ts
index 99c3770b..6a023e3e 100644
--- a/app/server/lib/configCore.ts
+++ b/app/server/lib/configCore.ts
@@ -25,7 +25,7 @@ export function loadGristCoreConfig(fileConfig?: FileConfig<IGristCoreConfigFile
   const fileConfigValue = fileConfigAccessorFactory(fileConfig);
   return {
     edition: createConfigValue<Edition>(
-      isAffirmative(process.env.TEST_ENABLE_ACTIVATION) ? "enterprise" : "core",
+      isAffirmative(process.env.GRIST_FORCE_ENABLE_ENTERPRISE) ? "enterprise" : "core",
       fileConfigValue("edition")
     )
   };

From e70c294e3d5ce2dd47f143fae3f72e3de3ce6d82 Mon Sep 17 00:00:00 2001
From: George Gevoian <george@gevoian.com>
Date: Tue, 13 Aug 2024 19:21:48 -0400
Subject: [PATCH 131/145] (core) Add custom widget gallery

Summary:
Custom widgets are now shown in a gallery.

The gallery is automatically opened when a new custom widget is
added to a page.

Descriptions, authors, and update times are pulled from the widget
manifest.

Test Plan: Browser tests.

Reviewers: jarek

Reviewed By: jarek

Subscribers: dsagal

Differential Revision: https://phab.getgrist.com/D4309
---
 app/client/components/GristDoc.ts             | 214 +++---
 app/client/components/WidgetFrame.ts          |   9 +-
 app/client/lib/koForm.css                     |   4 +
 app/client/models/AppModel.ts                 |  23 +-
 app/client/ui/CustomSectionConfig.ts          | 639 +++++++++--------
 app/client/ui/CustomWidgetGallery.ts          | 661 ++++++++++++++++++
 app/client/ui/GristTooltips.ts                |  26 +-
 .../ui/PredefinedCustomSectionConfig.ts       |  11 +-
 app/client/ui/RightPanel.ts                   |  24 +-
 app/client/ui/shadowScroll.ts                 |   3 +-
 app/client/ui2018/IconList.ts                 |   2 +
 app/client/ui2018/cssVars.ts                  |  18 +
 app/common/CustomWidget.ts                    |  25 +-
 app/common/Prefs.ts                           |   2 +-
 app/common/ThemePrefs-ti.ts                   |   9 +
 app/common/ThemePrefs.ts                      |  11 +
 app/common/gristUrls.ts                       |   3 +-
 app/common/themes/GristDark.ts                |  11 +
 app/common/themes/GristLight.ts               |  11 +
 static/icons/icons.css                        |   1 +
 static/ui-icons/UI/Question.svg               |   4 +
 test/nbrowser/AttachedCustomWidget.ts         |  10 +-
 test/nbrowser/BehavioralPrompts.ts            |  12 -
 test/nbrowser/CustomView.ts                   |  71 +-
 test/nbrowser/CustomWidgets.ts                | 411 +++++++----
 test/nbrowser/CustomWidgetsConfig.ts          | 121 ++--
 test/nbrowser/LinkingBidirectional.ts         |  10 +-
 test/nbrowser/RightPanel.ts                   |  21 +-
 test/nbrowser/SelectBy.ts                     |   2 +-
 test/nbrowser/ViewLayoutCollapse.ts           |   8 +-
 test/nbrowser/gristUtils.ts                   |  47 +-
 test/nbrowser/gristWebDriverUtils.ts          |  33 +-
 32 files changed, 1672 insertions(+), 785 deletions(-)
 create mode 100644 app/client/ui/CustomWidgetGallery.ts
 create mode 100644 static/ui-icons/UI/Question.svg

diff --git a/app/client/components/GristDoc.ts b/app/client/components/GristDoc.ts
index 5c7f2f7f..516bc14d 100644
--- a/app/client/components/GristDoc.ts
+++ b/app/client/components/GristDoc.ts
@@ -42,6 +42,7 @@ import {getFilterFunc, QuerySetManager} from 'app/client/models/QuerySet';
 import TableModel from 'app/client/models/TableModel';
 import {getUserOrgPrefObs, getUserOrgPrefsObs, markAsSeen} from 'app/client/models/UserPrefs';
 import {App} from 'app/client/ui/App';
+import {showCustomWidgetGallery} from 'app/client/ui/CustomWidgetGallery';
 import {DocHistory} from 'app/client/ui/DocHistory';
 import {startDocTour} from "app/client/ui/DocTour";
 import {DocTutorial} from 'app/client/ui/DocTutorial';
@@ -138,6 +139,13 @@ interface PopupSectionOptions {
   close: () => void;
 }
 
+interface AddSectionOptions {
+  /** If focus should move to the new section. Defaults to `true`. */
+  focus?: boolean;
+  /** If popups should be shown (e.g. Card Layout tip). Defaults to `true`. */
+  popups?: boolean;
+}
+
 export class GristDoc extends DisposableWithEvents {
   public docModel: DocModel;
   public viewModel: ViewRec;
@@ -894,38 +902,27 @@ export class GristDoc extends DisposableWithEvents {
   /**
    * Adds a view section described by val to the current page.
    */
-  public async addWidgetToPage(val: IPageWidget) {
-    const docData = this.docModel.docData;
-    const viewName = this.viewModel.name.peek();
+  public async addWidgetToPage(widget: IPageWidget) {
+    const {table, type} = widget;
     let tableId: string | null | undefined;
-    if (val.table === 'New Table') {
+    if (table === 'New Table') {
       tableId = await this._promptForName();
       if (tableId === undefined) {
         return;
       }
     }
-
-    const widgetType = getTelemetryWidgetTypeFromPageWidget(val);
-    logTelemetryEvent('addedWidget', {full: {docIdDigest: this.docId(), widgetType}});
-    if (val.link !== NoLink) {
-      logTelemetryEvent('linkedWidget', {full: {docIdDigest: this.docId(), widgetType}});
+    if (type === 'custom') {
+      return showCustomWidgetGallery(this, {
+        addWidget: () => this._addWidgetToPage(widget, tableId),
+      });
     }
 
-    const res: {sectionRef: number} = await docData.bundleActions(
+    const viewName = this.viewModel.name.peek();
+    const {sectionRef} = await this.docData.bundleActions(
       t("Added new linked section to view {{viewName}}", {viewName}),
-      () => this.addWidgetToPageImpl(val, tableId ?? null)
+      () => this._addWidgetToPage(widget, tableId ?? null)
     );
-
-    // The newly-added section should be given focus.
-    this.viewModel.activeSectionId(res.sectionRef);
-
-    this._maybeShowEditCardLayoutTip(val.type).catch(reportError);
-
-    if (AttachedCustomWidgets.guard(val.type)) {
-      this._handleNewAttachedCustomWidget(val.type).catch(reportError);
-    }
-
-    return res.sectionRef;
+    return sectionRef;
   }
 
   public async onCreateForm() {
@@ -941,80 +938,31 @@ export class GristDoc extends DisposableWithEvents {
     commands.allCommands.expandSection.run();
   }
 
-  /**
-   * The actual implementation of addWidgetToPage
-   */
-  public async addWidgetToPageImpl(val: IPageWidget, tableId: string | null = null) {
-    const viewRef = this.activeViewId.get();
-    const tableRef = val.table === 'New Table' ? 0 : val.table;
-    const result = await this.docData.sendAction(
-      ['CreateViewSection', tableRef, viewRef, val.type, val.summarize ? val.columns : null, tableId]
-    );
-    if (val.type === 'chart') {
-      await this._ensureOneNumericSeries(result.sectionRef);
-    }
-    if (val.type === 'form') {
-      await this._setDefaultFormLayoutSpec(result.sectionRef);
-    }
-    await this.saveLink(val.link, result.sectionRef);
-    return result;
-  }
-
   /**
    * Adds a new page (aka: view) with a single view section (aka: page widget) described by `val`.
    */
   public async addNewPage(val: IPageWidget) {
-    logTelemetryEvent('addedPage', {full: {docIdDigest: this.docId()}});
-    logTelemetryEvent('addedWidget', {
-      full: {
-        docIdDigest: this.docId(),
-        widgetType: getTelemetryWidgetTypeFromPageWidget(val),
-      },
-    });
-
-    let viewRef: IDocPage;
-    let sectionRef: number | undefined;
-    await this.docData.bundleActions('Add new page', async () => {
-      if (val.table === 'New Table') {
-        const name = await this._promptForName();
-        if (name === undefined) {
-          return;
-        }
-        if (val.type === WidgetType.Table) {
-          const result = await this.docData.sendAction(['AddEmptyTable', name]);
-          viewRef = result.views[0].id;
-        } else {
-          // This will create a new table and page.
-          const result = await this.docData.sendAction(
-            ['CreateViewSection', /* new table */0, 0, val.type, null, name]
-          );
-          [viewRef, sectionRef] = [result.viewRef, result.sectionRef];
-        }
-      } else {
-        const result = await this.docData.sendAction(
-          ['CreateViewSection', val.table, 0, val.type, val.summarize ? val.columns : null, null]
-        );
-        [viewRef, sectionRef] = [result.viewRef, result.sectionRef];
-        if (val.type === 'chart') {
-          await this._ensureOneNumericSeries(sectionRef!);
-        }
-      }
-      if (val.type === 'form') {
-        await this._setDefaultFormLayoutSpec(sectionRef!);
-      }
-    });
-
-    await this.openDocPage(viewRef!);
-    if (sectionRef) {
-      // The newly-added section should be given focus.
-      this.viewModel.activeSectionId(sectionRef);
+    const {table, type} = val;
+    let tableId: string | null | undefined;
+    if (table === 'New Table') {
+      tableId = await this._promptForName();
+      if (tableId === undefined) { return; }
+    }
+    if (type === 'custom') {
+      return showCustomWidgetGallery(this, {
+        addWidget: () => this._addPage(val, tableId ?? null) as Promise<{
+          viewRef: number;
+          sectionRef: number;
+        }>,
+      });
     }
 
-    this._maybeShowEditCardLayoutTip(val.type).catch(reportError);
-
-    if (AttachedCustomWidgets.guard(val.type)) {
-      this._handleNewAttachedCustomWidget(val.type).catch(reportError);
-    }
+    const {sectionRef, viewRef} = await this.docData.bundleActions(
+      'Add new page',
+      () => this._addPage(val, tableId ?? null)
+    );
+    await this._focus({sectionRef, viewRef});
+    this._showNewWidgetPopups(type);
   }
 
   /**
@@ -1460,6 +1408,90 @@ export class GristDoc extends DisposableWithEvents {
     return values;
   }
 
+  private async _addWidgetToPage(
+    widget: IPageWidget,
+    tableId: string | null = null,
+    {focus = true, popups = true}: AddSectionOptions= {}
+  ) {
+    const {columns, link, summarize, table, type} = widget;
+    const viewRef = this.activeViewId.get();
+    const tableRef = table === 'New Table' ? 0 : table;
+    const result: {viewRef: number, sectionRef: number} = await this.docData.sendAction(
+      ['CreateViewSection', tableRef, viewRef, type, summarize ? columns : null, tableId]
+    );
+    if (type === 'chart') {
+      await this._ensureOneNumericSeries(result.sectionRef);
+    }
+    if (type === 'form') {
+      await this._setDefaultFormLayoutSpec(result.sectionRef);
+    }
+    await this.saveLink(link, result.sectionRef);
+    const widgetType = getTelemetryWidgetTypeFromPageWidget(widget);
+    logTelemetryEvent('addedWidget', {full: {docIdDigest: this.docId(), widgetType}});
+    if (link !== NoLink) {
+      logTelemetryEvent('linkedWidget', {full: {docIdDigest: this.docId(), widgetType}});
+    }
+    if (focus) { await this._focus({sectionRef: result.sectionRef}); }
+    if (popups) { this._showNewWidgetPopups(type); }
+    return result;
+  }
+
+  private async _addPage(
+    widget: IPageWidget,
+    tableId: string | null = null,
+    {focus = true, popups = true}: AddSectionOptions = {}
+  ) {
+    const {columns, summarize, table, type} = widget;
+    let viewRef: number;
+    let sectionRef: number | undefined;
+    if (table === 'New Table') {
+      if (type === WidgetType.Table) {
+        const result = await this.docData.sendAction(['AddEmptyTable', tableId]);
+        viewRef = result.views[0].id;
+      } else {
+        // This will create a new table and page.
+        const result = await this.docData.sendAction(
+          ['CreateViewSection', 0, 0, type, null, tableId]
+        );
+        [viewRef, sectionRef] = [result.viewRef, result.sectionRef];
+      }
+    } else {
+      const result = await this.docData.sendAction(
+        ['CreateViewSection', table, 0, type, summarize ? columns : null, null]
+      );
+      [viewRef, sectionRef] = [result.viewRef, result.sectionRef];
+      if (type === 'chart') {
+        await this._ensureOneNumericSeries(sectionRef!);
+      }
+    }
+    if (type === 'form') {
+      await this._setDefaultFormLayoutSpec(sectionRef!);
+    }
+    logTelemetryEvent('addedPage', {full: {docIdDigest: this.docId()}});
+    logTelemetryEvent('addedWidget', {
+      full: {
+        docIdDigest: this.docId(),
+        widgetType: getTelemetryWidgetTypeFromPageWidget(widget),
+      },
+    });
+    if (focus) { await this._focus({viewRef, sectionRef}); }
+    if (popups) { this._showNewWidgetPopups(type); }
+    return {viewRef, sectionRef};
+  }
+
+  private async _focus({viewRef, sectionRef}: {viewRef?: number, sectionRef?: number}) {
+    if (viewRef) { await this.openDocPage(viewRef); }
+    if (sectionRef) { this.viewModel.activeSectionId(sectionRef); }
+  }
+
+  private _showNewWidgetPopups(type: IWidgetType) {
+    this._maybeShowEditCardLayoutTip(type).catch(reportError);
+
+    if (AttachedCustomWidgets.guard(type)) {
+      this._handleNewAttachedCustomWidget(type).catch(reportError);
+    }
+  }
+
   /**
    * Opens popup with a section data (used by Raw Data view).
    */
@@ -1718,7 +1750,7 @@ export class GristDoc extends DisposableWithEvents {
     const sectionId = section.id();
 
     // create a new section
-    const sectionCreationResult = await this.addWidgetToPageImpl(newVal);
+    const sectionCreationResult = await this._addWidgetToPage(newVal, null, {focus: false, popups: false});
 
     // update section name
     const newSection: ViewSectionRec = docModel.viewSections.getRowModel(sectionCreationResult.sectionRef);
diff --git a/app/client/components/WidgetFrame.ts b/app/client/components/WidgetFrame.ts
index 8a2b1bb8..ad7c24e6 100644
--- a/app/client/components/WidgetFrame.ts
+++ b/app/client/components/WidgetFrame.ts
@@ -223,10 +223,15 @@ export class WidgetFrame extends DisposableWithEvents {
 
   // Appends access level to query string.
   private _urlWithAccess(url: string) {
-    if (!url) {
+    if (!url) { return url; }
+
+    let urlObj: URL;
+    try {
+      urlObj = new URL(url);
+    } catch (e) {
+      console.error(e);
       return url;
     }
-    const urlObj = new URL(url);
     urlObj.searchParams.append('access', this._options.access);
     urlObj.searchParams.append('readonly', String(this._options.readonly));
     // Append user and document preferences to query string.
diff --git a/app/client/lib/koForm.css b/app/client/lib/koForm.css
index 3cb042a5..968d07ac 100644
--- a/app/client/lib/koForm.css
+++ b/app/client/lib/koForm.css
@@ -134,6 +134,10 @@ div:hover > .kf_tooltip {
   z-index: 11;
 }
 
+.kf_prompt_content:focus {
+  outline: none;
+}
+
 .kf_draggable {
   display: inline-block;
 }
diff --git a/app/client/models/AppModel.ts b/app/client/models/AppModel.ts
index 141bc4bc..addb7008 100644
--- a/app/client/models/AppModel.ts
+++ b/app/client/models/AppModel.ts
@@ -62,8 +62,6 @@ export interface TopAppModel {
   orgs: Observable<Organization[]>;
   users: Observable<FullUser[]>;
 
-  customWidgets: Observable<ICustomWidget[]|null>;
-
   // Reinitialize the app. This is called when org or user changes.
   initialize(): void;
 
@@ -162,26 +160,26 @@ export class TopAppModelImpl extends Disposable implements TopAppModel {
   public readonly orgs = Observable.create<Organization[]>(this, []);
   public readonly users = Observable.create<FullUser[]>(this, []);
   public readonly plugins: LocalPlugin[] = [];
-  public readonly customWidgets = Observable.create<ICustomWidget[]|null>(this, null);
-  private readonly _gristConfig?: GristLoadConfig;
+  private readonly _gristConfig? = this._window.gristConfig;
   // Keep a list of available widgets, once requested, so we don't have to
   // keep reloading it. Downside: browser page will need reloading to pick
   // up new widgets - that seems ok.
   private readonly _widgets: AsyncCreate<ICustomWidget[]>;
 
-  constructor(window: {gristConfig?: GristLoadConfig},
+  constructor(private _window: {gristConfig?: GristLoadConfig},
     public readonly api: UserAPI = newUserAPIImpl(),
     public readonly options: TopAppModelOptions = {}
   ) {
     super();
     setErrorNotifier(this.notifier);
-    this.isSingleOrg = Boolean(window.gristConfig && window.gristConfig.singleOrg);
-    this.productFlavor = getFlavor(window.gristConfig && window.gristConfig.org);
-    this._gristConfig = window.gristConfig;
+    this.isSingleOrg = Boolean(this._gristConfig?.singleOrg);
+    this.productFlavor = getFlavor(this._gristConfig?.org);
     this._widgets = new AsyncCreate<ICustomWidget[]>(async () => {
-      const widgets = this.options.useApi === false ? [] : await this.api.getWidgets();
-      this.customWidgets.set(widgets);
-      return widgets;
+      if (this.options.useApi === false || !this._gristConfig?.enableWidgetRepository) {
+        return [];
+      }
+
+      return await this.api.getWidgets();
     });
 
     // Initially, and on any change to subdomain, call initialize() to get the full Organization
@@ -214,8 +212,7 @@ export class TopAppModelImpl extends Disposable implements TopAppModel {
   public async testReloadWidgets() {
     console.log("testReloadWidgets");
     this._widgets.clear();
-    this.customWidgets.set(null);
-    console.log("testReloadWidgets cleared and nulled");
+    console.log("testReloadWidgets cleared");
     const result = await this.getWidgets();
     console.log("testReloadWidgets got", {result});
   }
diff --git a/app/client/ui/CustomSectionConfig.ts b/app/client/ui/CustomSectionConfig.ts
index b05fc6bf..9163bb11 100644
--- a/app/client/ui/CustomSectionConfig.ts
+++ b/app/client/ui/CustomSectionConfig.ts
@@ -1,11 +1,22 @@
 import {allCommands} from 'app/client/components/commands';
 import {GristDoc} from 'app/client/components/GristDoc';
 import {makeTestId} from 'app/client/lib/domUtils';
+import {FocusLayer} from 'app/client/lib/FocusLayer';
 import * as kf from 'app/client/lib/koForm';
 import {makeT} from 'app/client/lib/localization';
+import {localStorageBoolObs} from 'app/client/lib/localStorageObs';
 import {ColumnToMapImpl} from 'app/client/models/ColumnToMap';
 import {ColumnRec, ViewSectionRec} from 'app/client/models/DocModel';
-import {reportError} from 'app/client/models/errors';
+import {
+  cssDeveloperLink,
+  cssWidgetMetadata,
+  cssWidgetMetadataName,
+  cssWidgetMetadataRow,
+  cssWidgetMetadataValue,
+  CUSTOM_URL_WIDGET_ID,
+  getWidgetName,
+  showCustomWidgetGallery,
+} from 'app/client/ui/CustomWidgetGallery';
 import {cssHelp, cssLabel, cssRow, cssSeparator} from 'app/client/ui/RightPanelStyles';
 import {hoverTooltip} from 'app/client/ui/tooltips';
 import {cssDragRow, cssFieldEntry, cssFieldLabel} from 'app/client/ui/VisibleFieldsConfig';
@@ -14,16 +25,15 @@ import {theme, vars} from 'app/client/ui2018/cssVars';
 import {cssDragger} from 'app/client/ui2018/draggableList';
 import {textInput} from 'app/client/ui2018/editableLabel';
 import {icon} from 'app/client/ui2018/icons';
-import {cssLink} from 'app/client/ui2018/links';
 import {cssOptionLabel, IOption, IOptionFull, menu, menuItem, menuText, select} from 'app/client/ui2018/menus';
 import {AccessLevel, ICustomWidget, isSatisfied, matchWidget} from 'app/common/CustomWidget';
-import {GristLoadConfig} from 'app/common/gristUrls';
 import {not, unwrap} from 'app/common/gutil';
 import {
   bundleChanges,
   Computed,
   Disposable,
   dom,
+  DomContents,
   fromKo,
   MultiHolder,
   Observable,
@@ -33,22 +43,8 @@ import {
 
 const t = makeT('CustomSectionConfig');
 
-// Custom URL widget id - used as mock id for selectbox.
-const CUSTOM_ID = 'custom';
 const testId = makeTestId('test-config-widget-');
 
-/**
- * Custom Widget section.
- * Allows to select custom widget from the list of available widgets
- * (taken from /widgets endpoint), or enter a Custom URL.
- * When Custom Widget has a desired access level (in accessLevel field),
- * will prompt user to approve it. "None" access level is auto approved,
- * so prompt won't be shown.
- *
- * When gristConfig.enableWidgetRepository is set to false, it will only
- * allow to specify the custom URL.
- */
-
 class ColumnPicker extends Disposable {
   constructor(
     private _value: Observable<number|number[]|null>,
@@ -319,17 +315,17 @@ class ColumnListPicker extends Disposable {
 }
 
 class CustomSectionConfigurationConfig extends Disposable{
-  // Does widget has custom configuration.
-  private readonly _hasConfiguration: Computed<boolean>;
+  private readonly _hasConfiguration = Computed.create(this, use =>
+    Boolean(use(this._section.hasCustomOptions) || use(this._section.columnsToMap)));
+
   constructor(private _section: ViewSectionRec, private _gristDoc: GristDoc) {
     super();
-    this._hasConfiguration = Computed.create(this, use => use(_section.hasCustomOptions));
   }
+
   public buildDom() {
-    // Show prompt, when desired access level is different from actual one.
-    return dom(
-      'div',
-      dom.maybe(this._hasConfiguration, () =>
+    return dom.maybe(this._hasConfiguration, () => [
+      cssSeparator(),
+      dom.maybe(this._section.hasCustomOptions, () =>
         cssSection(
           textButton(
             t("Open configuration"),
@@ -363,7 +359,7 @@ class CustomSectionConfigurationConfig extends Disposable{
             : dom.create(ColumnPicker, m.value, m.column, this._section)),
         );
       })
-    );
+    ]);
   }
   private _openConfiguration(): void {
     allCommands.openWidgetConfiguration.run();
@@ -384,274 +380,107 @@ class CustomSectionConfigurationConfig extends Disposable{
   }
 }
 
+/**
+ * Custom widget configuration.
+ *
+ * Allows picking a custom widget from a gallery of available widgets
+ * (fetched from the `/widgets` endpoint), which includes the Custom URL
+ * widget.
+ *
+ * When a custom widget has a desired `accessLevel` set to a value other
+ * than `"None"`, a prompt will be shown to grant the requested access level
+ * to the widget.
+ *
+ * When `gristConfig.enableWidgetRepository` is set to false, only the
+ * Custom URL widget will be available to select in the gallery.
+ */
 export class CustomSectionConfig extends Disposable {
+  protected _customSectionConfigurationConfig = new CustomSectionConfigurationConfig(
+    this._section, this._gristDoc);
 
-  protected _customSectionConfigurationConfig: CustomSectionConfigurationConfig;
-  // Holds all available widget definitions.
-  private _widgets: Observable<ICustomWidget[]|null>;
-  // Holds selected option (either custom string or a widgetId).
-  private readonly _selectedId: Computed<string | null>;
-  // Holds custom widget URL.
-  private readonly _url: Computed<string>;
-  // Enable or disable widget repository.
-  private readonly _canSelect: boolean = true;
-  // When widget is changed, it sets its desired access level. We will prompt
-  // user to approve or reject it.
-  private readonly _desiredAccess: Observable<AccessLevel|null>;
-  // Current access level (stored inside a section).
-  private readonly _currentAccess: Computed<AccessLevel>;
+  private readonly _widgetId = Computed.create(this, use => {
+    // Stored in one of two places, depending on age of document.
+    const widgetId = use(this._section.customDef.widgetId) ||
+      use(this._section.customDef.widgetDef)?.widgetId;
+    if (widgetId) {
+      const pluginId = use(this._section.customDef.pluginId);
+      return (pluginId || '') + ':' + widgetId;
+    } else {
+      return CUSTOM_URL_WIDGET_ID;
+    }
+  });
 
+  private readonly _isCustomUrlWidget = Computed.create(this, this._widgetId, (_use, widgetId) => {
+    return widgetId === CUSTOM_URL_WIDGET_ID;
+  });
 
+  private readonly _currentAccess = Computed.create(this, use =>
+    (use(this._section.customDef.access) as AccessLevel) || AccessLevel.none)
+    .onWrite(async newAccess => {
+      await this._section.customDef.access.setAndSave(newAccess);
+    });
 
+  private readonly _desiredAccess = fromKo(this._section.desiredAccessLevel);
+
+  private readonly _url = Computed.create(this, use => use(this._section.customDef.url) || '')
+    .onWrite(async newUrl => {
+      bundleChanges(() => {
+        this._section.customDef.renderAfterReady(false);
+        if (newUrl) {
+          this._section.customDef.widgetId(null);
+          this._section.customDef.pluginId('');
+          this._section.customDef.widgetDef(null);
+        }
+        this._section.customDef.url(newUrl);
+      });
+      await this._section.saveCustomDef();
+    });
+
+  private readonly _requiresAccess = Computed.create(this, use => {
+    const [currentAccess, desiredAccess] = [use(this._currentAccess), use(this._desiredAccess)];
+    return desiredAccess && !isSatisfied(currentAccess, desiredAccess);
+  });
+
+  private readonly _widgetDetailsExpanded: Observable<boolean>;
+
+  private readonly _widgets: Observable<ICustomWidget[] | null> = Observable.create(this, null);
+
+  private readonly _selectedWidget = Computed.create(this, use => {
+    const id = use(this._widgetId);
+    if (id === CUSTOM_URL_WIDGET_ID) { return null; }
+
+    const widgets = use(this._widgets);
+    if (!widgets) { return null; }
+
+    const [pluginId, widgetId] = id.split(':');
+    return matchWidget(widgets, {pluginId, widgetId}) ?? null;
+  });
 
   constructor(protected _section: ViewSectionRec, private _gristDoc: GristDoc) {
     super();
-    this._customSectionConfigurationConfig = new CustomSectionConfigurationConfig(_section, _gristDoc);
 
-    // Test if we can offer widget list.
-    const gristConfig: GristLoadConfig = (window as any).gristConfig || {};
-    this._canSelect = gristConfig.enableWidgetRepository ?? true;
+    const userId = this._gristDoc.appModel.currentUser?.id ?? 0;
+    this._widgetDetailsExpanded = this.autoDispose(localStorageBoolObs(
+      `u:${userId};customWidgetDetailsExpanded`,
+      true
+    ));
 
-    // Array of available widgets - will be updated asynchronously.
-    this._widgets = _gristDoc.app.topAppModel.customWidgets;
-    this._getWidgets().catch(reportError);
-    // Request for rest of the widgets.
+    this._getWidgets()
+      .then(widgets => {
+        if (this.isDisposed()) { return; }
 
-    // Selected value from the dropdown (contains widgetId or "custom" string for Custom URL)
-    this._selectedId = Computed.create(this, use => {
-      // widgetId could be stored in one of two places, depending on
-      // age of document.
-      const widgetId = use(_section.customDef.widgetId) ||
-          use(_section.customDef.widgetDef)?.widgetId;
-      const pluginId = use(_section.customDef.pluginId);
-      if (widgetId) {
-        // selection id is "pluginId:widgetId"
-        return (pluginId || '') + ':' + widgetId;
-      }
-      return CUSTOM_ID;
-    });
-    this._selectedId.onWrite(async value => {
-      if (value === CUSTOM_ID) {
-        // Select Custom URL
-        bundleChanges(() => {
-          // Reset whether widget should render after `grist.ready()`.
-          _section.customDef.renderAfterReady(false);
-          // Clear url.
-          _section.customDef.url(null);
-          // Clear widgetId
-          _section.customDef.widgetId(null);
-          _section.customDef.widgetDef(null);
-          // Clear pluginId
-          _section.customDef.pluginId('');
-          // Reset access level to none.
-          _section.customDef.access(AccessLevel.none);
-          // Clear all saved options.
-          _section.customDef.widgetOptions(null);
-          // Reset custom configuration flag.
-          _section.hasCustomOptions(false);
-          // Clear column mappings.
-          _section.customDef.columnsMapping(null);
-          _section.columnsToMap(null);
-          this._desiredAccess.set(AccessLevel.none);
-        });
-        await _section.saveCustomDef();
-      } else {
-        const [pluginId, widgetId] = value?.split(':') || [];
-        // Select Widget
-        const selectedWidget = matchWidget(this._widgets.get()||[], {
-          widgetId,
-          pluginId,
-        });
-        if (!selectedWidget) {
-          // should not happen
-          throw new Error('Error accessing widget from the list');
-        }
-        // If user selected the same one, do nothing.
-        if (_section.customDef.widgetId.peek() === widgetId &&
-            _section.customDef.pluginId.peek() === pluginId) {
-          return;
-        }
-        bundleChanges(() => {
-          // Reset whether widget should render after `grist.ready()`.
-          _section.customDef.renderAfterReady(selectedWidget.renderAfterReady ?? false);
-          // Clear access level
-          _section.customDef.access(AccessLevel.none);
-          // When widget wants some access, set desired access level.
-          this._desiredAccess.set(selectedWidget.accessLevel || AccessLevel.none);
-
-          // Keep a record of the original widget definition.
-          // Don't rely on this much, since the document could
-          // have moved installation since, and widgets could be
-          // served from elsewhere.
-          _section.customDef.widgetDef(selectedWidget);
-
-          // Update widgetId.
-          _section.customDef.widgetId(selectedWidget.widgetId);
-          // Update pluginId.
-          _section.customDef.pluginId(selectedWidget.source?.pluginId || '');
-          // Update widget URL. Leave blank when widgetId is set.
-          _section.customDef.url(null);
-          // Clear options.
-          _section.customDef.widgetOptions(null);
-          // Clear has custom configuration.
-          _section.hasCustomOptions(false);
-          // Clear column mappings.
-          _section.customDef.columnsMapping(null);
-          _section.columnsToMap(null);
-        });
-        await _section.saveCustomDef();
-      }
-    });
-
-    // Url for the widget, taken either from widget definition, or provided by hand for Custom URL.
-    // For custom widget, we will store url also in section definition.
-    this._url = Computed.create(this, use => use(_section.customDef.url) || '');
-    this._url.onWrite(async newUrl => {
-      bundleChanges(() => {
-        _section.customDef.renderAfterReady(false);
-        if (newUrl) {
-          // When a URL is set explicitly, make sure widgetId/pluginId/widgetDef
-          // is empty.
-          _section.customDef.widgetId(null);
-          _section.customDef.pluginId('');
-          _section.customDef.widgetDef(null);
-        }
-        _section.customDef.url(newUrl);
-      });
-      await _section.saveCustomDef();
-    });
-
-    // Compute current access level.
-    this._currentAccess = Computed.create(
-      this,
-      use => (use(_section.customDef.access) as AccessLevel) || AccessLevel.none
-    );
-    this._currentAccess.onWrite(async newAccess => {
-      await _section.customDef.access.setAndSave(newAccess);
-    });
-    // From the start desired access level is the same as current one.
-    this._desiredAccess = fromKo(_section.desiredAccessLevel);
+        this._widgets.set(widgets);
+      })
+      .catch(reportError);
 
     // Clear intermediate state when section changes.
-    this.autoDispose(_section.id.subscribe(() => this._reject()));
+    this.autoDispose(_section.id.subscribe(() => this._dismissAccessPrompt()));
   }
 
-  public buildDom() {
-    // UI observables holder.
-    const holder = new MultiHolder();
-
-    // Show prompt, when desired access level is different from actual one.
-    const prompt = Computed.create(holder, use =>
-      use(this._desiredAccess)
-      && !isSatisfied(use(this._currentAccess), use(this._desiredAccess)!));
-    // If this is empty section or not.
-    const isSelected = Computed.create(holder, use => Boolean(use(this._selectedId)));
-    // If user is using custom url.
-    const isCustom = Computed.create(holder, use => use(this._selectedId) === CUSTOM_ID || !this._canSelect);
-    // Options for the select-box (all widgets definitions and Custom URL)
-    const options = Computed.create(holder, use => [
-      {label: 'Custom URL', value: 'custom'},
-      ...(use(this._widgets) || [])
-           .filter(w => w?.published !== false)
-           .map(w => ({
-             label: w.source?.name ? `${w.name} (${w.source.name})` : w.name,
-             value: (w.source?.pluginId || '') + ':' + w.widgetId,
-           })),
-    ]);
-    function buildPrompt(level: AccessLevel|null) {
-      if (!level) {
-        return null;
-      }
-      switch(level) {
-        case AccessLevel.none: return cssConfirmLine(t("Widget does not require any permissions."));
-        case AccessLevel.read_table:
-          return cssConfirmLine(t("Widget needs to {{read}} the current table.", {read: dom("b", "read")}));
-        case AccessLevel.full:
-          return cssConfirmLine(t("Widget needs {{fullAccess}} to this document.", {
-            fullAccess: dom("b", "full access")
-          }));
-        default: throw new Error(`Unsupported ${level} access level`);
-      }
-    }
-    // Options for access level.
-    const levels: IOptionFull<string>[] = [
-      {label: t("No document access"), value: AccessLevel.none},
-      {label: t("Read selected table"), value: AccessLevel.read_table},
-      {label: t("Full document access"), value: AccessLevel.full},
-    ];
-    return dom(
-      'div',
-      dom.autoDispose(holder),
-      this.shouldRenderWidgetSelector() &&
-      this._canSelect
-        ? cssRow(
-          select(this._selectedId, options, {
-            defaultLabel: t("Select Custom Widget"),
-            menuCssClass: cssMenu.className,
-          }),
-          testId('select')
-        )
-        : null,
-      dom.maybe((use) => use(isCustom) && this.shouldRenderWidgetSelector(), () => [
-        cssRow(
-          cssTextInput(
-            this._url,
-            async value => this._url.set(value),
-            dom.attr('placeholder', t("Enter Custom URL")),
-            testId('url')
-          ),
-          this._gristDoc.behavioralPromptsManager.attachPopup('customURL', {
-            popupOptions: {
-              placement: 'left-start',
-            },
-            isDisabled: () => {
-              // Disable tip if a custom widget is already selected.
-              return Boolean(this._selectedId.get() && !(isCustom.get() && this._url.get().trim() === ''));
-            },
-          })
-        ),
-      ]),
-      dom.maybe(prompt, () =>
-        kf.prompt(
-          {tabindex: '-1'},
-          cssColumns(
-            cssWarningWrapper(icon('Lock')),
-            dom(
-              'div',
-              cssConfirmRow(
-                dom.domComputed(this._desiredAccess, (level) => buildPrompt(level))
-              ),
-              cssConfirmRow(
-                primaryButton(
-                  'Accept',
-                  testId('access-accept'),
-                  dom.on('click', () => this._accept())
-                ),
-                basicButton(
-                  'Reject',
-                  testId('access-reject'),
-                  dom.on('click', () => this._reject())
-                )
-              )
-            )
-          )
-        )
-      ),
-      dom.maybe(
-        use => use(isSelected) || !this._canSelect,
-        () => [
-          cssLabel('ACCESS LEVEL'),
-          cssRow(select(this._currentAccess, levels), testId('access')),
-        ]
-      ),
-      cssSection(
-        cssLink(
-          dom.attr('href', 'https://support.getgrist.com/widget-custom'),
-          dom.attr('target', '_blank'),
-          t("Learn more about custom widgets")
-        )
-      ),
-      cssSeparator(),
+  public buildDom(): DomContents {
+    return dom('div',
+      this._buildWidgetSelector(),
+      this._buildAccessLevelConfig(),
       this._customSectionConfigurationConfig.buildDom(),
     );
   }
@@ -661,21 +490,194 @@ export class CustomSectionConfig extends Disposable {
   }
 
   protected async _getWidgets() {
-    await this._gristDoc.app.topAppModel.getWidgets();
+    return await this._gristDoc.app.topAppModel.getWidgets();
   }
 
-  private _accept() {
+  private _buildWidgetSelector() {
+    if (!this.shouldRenderWidgetSelector()) { return null; }
+
+    return [
+      cssRow(
+        cssWidgetSelector(
+          this._buildShowWidgetDetailsButton(),
+          this._buildWidgetName(),
+        ),
+      ),
+      this._maybeBuildWidgetDetails(),
+    ];
+  }
+
+  private _buildShowWidgetDetailsButton() {
+    return cssShowWidgetDetails(
+      cssShowWidgetDetailsIcon(
+        'Dropdown',
+        cssShowWidgetDetailsIcon.cls('-collapsed', use => !use(this._widgetDetailsExpanded)),
+        testId('toggle-custom-widget-details'),
+        testId(use => !use(this._widgetDetailsExpanded)
+          ? 'show-custom-widget-details'
+          : 'hide-custom-widget-details'
+        ),
+      ),
+      cssWidgetLabel(t('Widget')),
+      dom.on('click', () => {
+        this._widgetDetailsExpanded.set(!this._widgetDetailsExpanded.get());
+      }),
+    );
+  }
+
+  private _buildWidgetName() {
+    return cssWidgetName(
+      dom.text(use => {
+        if (use(this._isCustomUrlWidget)) {
+          return t('Custom URL');
+        } else {
+          const widget = use(this._selectedWidget) ?? use(this._section.customDef.widgetDef);
+          return widget ? getWidgetName(widget) : use(this._widgetId);
+        }
+      }),
+      dom.on('click', () => showCustomWidgetGallery(this._gristDoc, {
+        sectionRef: this._section.id(),
+      })),
+      testId('open-custom-widget-gallery'),
+    );
+  }
+
+  private _maybeBuildWidgetDetails() {
+    return dom.maybe(this._widgetDetailsExpanded, () =>
+      dom.domComputed(this._selectedWidget, (widget) =>
+        cssRow(
+          this._buildWidgetDetails(widget),
+        )
+      )
+    );
+  }
+
+  private _buildWidgetDetails(widget: ICustomWidget | null) {
+    return dom.domComputed(this._isCustomUrlWidget, (isCustomUrlWidget) => {
+      if (isCustomUrlWidget) {
+        return cssCustomUrlDetails(
+          cssTextInput(
+            this._url,
+            async value => this._url.set(value),
+            dom.show(this._isCustomUrlWidget),
+            {placeholder: t('Enter Custom URL')},
+          ),
+        );
+      } else if (!widget?.description && !widget?.authors?.[0] && !widget?.lastUpdatedAt) {
+        return cssDetailsMessage(t('Missing description and author information.'));
+      } else {
+        return cssWidgetDetails(
+          !widget?.description ? null : cssWidgetDescription(
+            widget.description,
+            testId('custom-widget-description'),
+          ),
+          cssWidgetMetadata(
+            !widget?.authors?.[0] ? null : cssWidgetMetadataRow(
+              cssWidgetMetadataName(t('Developer:')),
+              cssWidgetMetadataValue(
+                widget.authors[0].url
+                  ? cssDeveloperLink(
+                    widget.authors[0].name,
+                    {href: widget.authors[0].url, target: '_blank'},
+                    testId('custom-widget-developer'),
+                  )
+                  : dom('span',
+                    widget.authors[0].name,
+                    testId('custom-widget-developer'),
+                  ),
+                testId('custom-widget-developer'),
+              ),
+            ),
+            !widget?.lastUpdatedAt ? null : cssWidgetMetadataRow(
+              cssWidgetMetadataName(t('Last updated:')),
+              cssWidgetMetadataValue(
+                new Date(widget.lastUpdatedAt).toLocaleDateString('default', {
+                  month: 'long',
+                  day: 'numeric',
+                  year: 'numeric',
+                }),
+                testId('custom-widget-last-updated'),
+              ),
+            ),
+          )
+        );
+      }
+    });
+  }
+
+  private _buildAccessLevelConfig() {
+    return [
+      cssSeparator({style: 'margin-top: 0px'}),
+      cssLabel(t('ACCESS LEVEL')),
+      cssRow(select(this._currentAccess, getAccessLevels()), testId('access')),
+      dom.maybeOwned(this._requiresAccess, (owner) => kf.prompt(
+        (elem: HTMLDivElement) => { FocusLayer.create(owner, {defaultFocusElem: elem, pauseMousetrap: true}); },
+        cssColumns(
+          cssWarningWrapper(icon('Lock')),
+          dom('div',
+            cssConfirmRow(
+              dom.domComputed(this._desiredAccess, (level) => this._buildAccessLevelPrompt(level))
+            ),
+            cssConfirmRow(
+              primaryButton(
+                t('Accept'),
+                testId('access-accept'),
+                dom.on('click', () => this._grantDesiredAccess())
+              ),
+              basicButton(
+                t('Reject'),
+                testId('access-reject'),
+                dom.on('click', () => this._dismissAccessPrompt())
+              )
+            )
+          )
+        ),
+        dom.onKeyDown({
+          Enter: () => this._grantDesiredAccess(),
+          Escape:() => this._dismissAccessPrompt(),
+        }),
+      )),
+    ];
+  }
+
+  private _buildAccessLevelPrompt(level: AccessLevel | null) {
+    if (!level) { return null; }
+
+    switch (level) {
+      case AccessLevel.none: {
+        return cssConfirmLine(t("Widget does not require any permissions."));
+      }
+      case AccessLevel.read_table: {
+        return cssConfirmLine(t("Widget needs to {{read}} the current table.", {read: dom("b", "read")}));
+      }
+      case AccessLevel.full: {
+        return cssConfirmLine(t("Widget needs {{fullAccess}} to this document.", {
+          fullAccess: dom("b", "full access")
+        }));
+      }
+    }
+  }
+
+  private _grantDesiredAccess() {
     if (this._desiredAccess.get()) {
       this._currentAccess.set(this._desiredAccess.get()!);
     }
-    this._reject();
+    this._dismissAccessPrompt();
   }
 
-  private _reject() {
+  private _dismissAccessPrompt() {
     this._desiredAccess.set(null);
   }
 }
 
+function getAccessLevels(): IOptionFull<string>[] {
+  return [
+    {label: t("No document access"), value: AccessLevel.none},
+    {label: t("Read selected table"), value: AccessLevel.read_table},
+    {label: t("Full document access"), value: AccessLevel.full},
+  ];
+}
+
 const cssWarningWrapper = styled('div', `
   padding-left: 8px;
   padding-top: 6px;
@@ -700,12 +702,6 @@ const cssSection = styled('div', `
   margin: 16px 16px 12px 16px;
 `);
 
-const cssMenu = styled('div', `
-  & > li:first-child {
-    border-bottom: 1px solid ${theme.menuBorder};
-  }
-`);
-
 const cssAddIcon = styled(icon, `
   margin-right: 4px;
 `);
@@ -748,17 +744,9 @@ const cssAddMapping = styled('div', `
 `);
 
 const cssTextInput = styled(textInput, `
-  flex: 1 0 auto;
-
   color: ${theme.inputFg};
   background-color: ${theme.inputBg};
 
-  &:disabled {
-    color: ${theme.inputDisabledFg};
-    background-color: ${theme.inputDisabledBg};
-    pointer-events: none;
-  }
-
   &::placeholder {
     color: ${theme.inputPlaceholderFg};
   }
@@ -771,3 +759,62 @@ const cssDisabledSelect = styled(select, `
 const cssBlank = styled(cssOptionLabel, `
   --grist-option-label-color: ${theme.lightText};
 `);
+
+const cssWidgetSelector = styled('div', `
+  width: 100%;
+  display: flex;
+  justify-content: space-between;
+  column-gap: 16px;
+`);
+
+const cssShowWidgetDetails = styled('div', `
+  display: flex;
+  align-items: center;
+  column-gap: 4px;
+  cursor: pointer;
+`);
+
+const cssShowWidgetDetailsIcon = styled(icon, `
+  --icon-color: ${theme.lightText};
+  flex-shrink: 0;
+
+  &-collapsed {
+    transform: rotate(-90deg);
+  }
+`);
+
+const cssWidgetLabel = styled('div', `
+  text-transform: uppercase;
+  font-size: ${vars.xsmallFontSize};
+`);
+
+const cssWidgetName = styled('div', `
+  color: ${theme.rightPanelCustomWidgetButtonFg};
+  background-color: ${theme.rightPanelCustomWidgetButtonBg};
+  height: 24px;
+  padding: 4px 8px;
+  border-radius: 4px;
+  cursor: pointer;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+`);
+
+const cssWidgetDetails = styled('div', `
+  margin-top: 8px;
+  display: flex;
+  flex-direction: column;
+  margin-bottom: 8px;
+`);
+
+const cssCustomUrlDetails = styled(cssWidgetDetails, `
+  flex: 1 0 auto;
+`);
+
+const cssDetailsMessage = styled('div', `
+  color: ${theme.lightText};
+`);
+
+const cssWidgetDescription = styled('div', `
+  margin-bottom: 16px;
+`);
diff --git a/app/client/ui/CustomWidgetGallery.ts b/app/client/ui/CustomWidgetGallery.ts
new file mode 100644
index 00000000..2f22de18
--- /dev/null
+++ b/app/client/ui/CustomWidgetGallery.ts
@@ -0,0 +1,661 @@
+import {GristDoc} from 'app/client/components/GristDoc';
+import {makeT} from 'app/client/lib/localization';
+import {ViewSectionRec} from 'app/client/models/DocModel';
+import {textInput} from 'app/client/ui/inputs';
+import {shadowScroll} from 'app/client/ui/shadowScroll';
+import {withInfoTooltip} from 'app/client/ui/tooltips';
+import {bigBasicButton, bigPrimaryButton} from 'app/client/ui2018/buttons';
+import {theme} from 'app/client/ui2018/cssVars';
+import {icon} from 'app/client/ui2018/icons';
+import {cssLink} from 'app/client/ui2018/links';
+import {loadingSpinner} from 'app/client/ui2018/loaders';
+import {IModalControl, modal} from 'app/client/ui2018/modals';
+import {AccessLevel, ICustomWidget, matchWidget, WidgetAuthor} from 'app/common/CustomWidget';
+import {commonUrls} from 'app/common/gristUrls';
+import {bundleChanges, Computed, Disposable, dom, makeTestId, Observable, styled} from 'grainjs';
+import escapeRegExp from 'lodash/escapeRegExp';
+
+const testId = makeTestId('test-custom-widget-gallery-');
+
+const t = makeT('CustomWidgetGallery');
+
+export const CUSTOM_URL_WIDGET_ID = 'custom';
+
+interface Options {
+  sectionRef?: number;
+  addWidget?(): Promise<{viewRef: number, sectionRef: number}>;
+}
+
+export function showCustomWidgetGallery(gristDoc: GristDoc, options: Options = {}) {
+  modal((ctl) => [
+    dom.create(CustomWidgetGallery, ctl, gristDoc, options),
+    cssModal.cls(''),
+  ]);
+}
+
+interface WidgetInfo {
+  variant: WidgetVariant;
+  id: string;
+  name: string;
+  description?: string;
+  developer?: WidgetAuthor;
+  lastUpdated?: string;
+}
+
+interface CustomWidgetACItem extends ICustomWidget {
+  cleanText: string;
+}
+
+type WidgetVariant = 'custom' | 'grist' | 'community';
+
+class CustomWidgetGallery extends Disposable {
+  private readonly _customUrl: Observable<string>;
+  private readonly _filteredWidgets = Observable.create<ICustomWidget[] | null>(this, null);
+  private readonly _section: ViewSectionRec | null = null;
+  private readonly _searchText = Observable.create(this, '');
+  private readonly _saveDisabled: Computed<boolean>;
+  private readonly _savedWidgetId: Computed<string | null>;
+  private readonly _selectedWidgetId = Observable.create<string | null>(this, null);
+  private readonly _widgets = Observable.create<CustomWidgetACItem[] | null>(this, null);
+
+  constructor(
+    private _ctl: IModalControl,
+    private _gristDoc: GristDoc,
+    private _options: Options = {}
+  ) {
+    super();
+
+    const {sectionRef} = _options;
+    if (sectionRef) {
+      const section = this._gristDoc.docModel.viewSections.getRowModel(sectionRef);
+      if (!section.id.peek()) {
+        throw new Error(`Section ${sectionRef} does not exist`);
+      }
+
+      this._section = section;
+      this.autoDispose(section._isDeleted.subscribe((isDeleted) => {
+        if (isDeleted) { this._ctl.close(); }
+      }));
+    }
+
+    let customUrl = '';
+    if (this._section) {
+      customUrl = this._section.customDef.url() ?? '';
+    }
+    this._customUrl = Observable.create(this, customUrl);
+
+    this._savedWidgetId = Computed.create(this, (use) => {
+      if (!this._section) { return null; }
+
+      const {customDef} = this._section;
+      // May be stored in one of two places, depending on age of document.
+      const widgetId = use(customDef.widgetId) || use(customDef.widgetDef)?.widgetId;
+      if (widgetId) {
+        const pluginId = use(customDef.pluginId);
+        const widget = matchWidget(use(this._widgets) ?? [], {
+          widgetId,
+          pluginId,
+        });
+        return widget ? `${pluginId}:${widgetId}` : null;
+      } else {
+        return CUSTOM_URL_WIDGET_ID;
+      }
+    });
+
+    this._saveDisabled = Computed.create(this, use => {
+      const selectedWidgetId = use(this._selectedWidgetId);
+      if (!selectedWidgetId) { return true; }
+      if (!this._section) { return false; }
+
+      const savedWidgetId = use(this._savedWidgetId);
+      if (selectedWidgetId === CUSTOM_URL_WIDGET_ID) {
+        return (
+          use(this._savedWidgetId) === CUSTOM_URL_WIDGET_ID &&
+          use(this._customUrl) === use(this._section.customDef.url)
+        );
+      } else {
+        return selectedWidgetId === savedWidgetId;
+      }
+    });
+
+    this._initializeWidgets().catch(reportError);
+
+    this.autoDispose(this._searchText.addListener(() => {
+      this._filterWidgets();
+      this._selectedWidgetId.set(null);
+    }));
+  }
+
+  public buildDom() {
+    return cssCustomWidgetGallery(
+      cssHeader(
+        cssTitle(t('Choose Custom Widget')),
+        cssSearchInputWrapper(
+          cssSearchIcon('Search'),
+          cssSearchInput(
+            this._searchText,
+            {placeholder: t('Search')},
+            (el) => { setTimeout(() => el.focus(), 10); },
+            testId('search'),
+          ),
+        ),
+      ),
+      shadowScroll(
+        this._buildWidgets(),
+        cssShadowScroll.cls(''),
+      ),
+      cssFooter(
+        dom('div',
+          cssHelpLink(
+            {href: commonUrls.helpCustomWidgets, target: '_blank'},
+            cssHelpIcon('Question'),
+            t('Learn more about Custom Widgets'),
+          ),
+        ),
+        cssFooterButtons(
+          bigBasicButton(
+            t('Cancel'),
+            dom.on('click', () => this._ctl.close()),
+            testId('cancel'),
+          ),
+          bigPrimaryButton(
+            this._options.addWidget ? t('Add Widget') : t('Change Widget'),
+            dom.on('click', () => this._save()),
+            dom.boolAttr('disabled', this._saveDisabled),
+            testId('save'),
+          ),
+        ),
+      ),
+      dom.onKeyDown({
+        Enter: () => this._save(),
+        Escape: () => this._deselectOrClose(),
+      }),
+      dom.on('click', (ev) => this._maybeClearSelection(ev)),
+      testId('container'),
+    );
+  }
+
+  private async _initializeWidgets() {
+    const widgets: ICustomWidget[] = [
+      {
+        widgetId: 'custom',
+        name: t('Custom URL'),
+        description: t('Add a widget from outside this gallery.'),
+        url: '',
+      },
+    ];
+    try {
+      const remoteWidgets = await this._gristDoc.appModel.topAppModel.getWidgets();
+      if (this.isDisposed()) { return; }
+
+      widgets.push(...remoteWidgets
+        .filter(({published}) => published !== false)
+        .sort((a, b) => a.name.localeCompare(b.name)));
+    } catch (e) {
+      reportError(e);
+    }
+
+    this._widgets.set(widgets.map(w => ({...w, cleanText: getWidgetCleanText(w)})));
+    this._selectedWidgetId.set(this._savedWidgetId.get());
+    this._filterWidgets();
+  }
+
+  private _filterWidgets() {
+    const widgets = this._widgets.get();
+    if (!widgets) { return; }
+
+    const searchText = this._searchText.get();
+    if (!searchText) {
+      this._filteredWidgets.set(widgets);
+    } else {
+      const searchTerms = searchText.trim().split(/\s+/);
+      const searchPatterns = searchTerms.map(term =>
+        new RegExp(`\\b${escapeRegExp(term)}`, 'i'));
+      const filteredWidgets = widgets.filter(({cleanText}) =>
+        searchPatterns.some(pattern => pattern.test(cleanText))
+      );
+      this._filteredWidgets.set(filteredWidgets);
+    }
+  }
+
+  private _buildWidgets() {
+    return dom.domComputed(this._filteredWidgets, (widgets) => {
+      if (widgets === null) {
+        return cssLoadingSpinner(loadingSpinner());
+      } else if (widgets.length === 0) {
+        return cssNoMatchingWidgets(t('No matching widgets'));
+      } else {
+        return cssWidgets(
+          widgets.map(widget => {
+            const {description, authors = [], lastUpdatedAt} = widget;
+
+            return this._buildWidget({
+              variant: getWidgetVariant(widget),
+              id: getWidgetId(widget),
+              name: getWidgetName(widget),
+              description,
+              developer: authors[0],
+              lastUpdated: lastUpdatedAt,
+            });
+          }),
+        );
+      }
+    });
+  }
+
+  private _buildWidget(info: WidgetInfo) {
+    const {variant, id, name, description, developer, lastUpdated} = info;
+
+    return cssWidget(
+      dom.cls('custom-widget'),
+      cssWidgetHeader(
+        variant === 'custom' ? t('Add Your Own Widget') :
+        variant === 'grist' ? t('Grist Widget') :
+        withInfoTooltip(
+          t('Community Widget'),
+          'communityWidgets',
+          {
+            variant: 'hover',
+            iconDomArgs: [cssTooltipIcon.cls('')],
+          }
+        ),
+        cssWidgetHeader.cls('-secondary', ['custom', 'community'].includes(variant)),
+      ),
+      cssWidgetBody(
+        cssWidgetName(
+          name,
+          testId('widget-name'),
+        ),
+        cssWidgetDescription(
+          description ?? t('(Missing info)'),
+          cssWidgetDescription.cls('-missing', !description),
+          testId('widget-description'),
+        ),
+        variant === 'custom' ? null : cssWidgetMetadata(
+          variant === 'grist' ? null : cssWidgetMetadataRow(
+            cssWidgetMetadataName(t('Developer:')),
+            cssWidgetMetadataValue(
+              developer?.url
+                ? cssDeveloperLink(
+                  developer.name,
+                  {href: developer.url, target: '_blank'},
+                  dom.on('click', (ev) => ev.stopPropagation()),
+                  testId('widget-developer'),
+                )
+                : dom('span',
+                  developer?.name ?? t('(Missing info)'),
+                  testId('widget-developer'),
+                ),
+              cssWidgetMetadataValue.cls('-missing', !developer?.name),
+              testId('widget-developer'),
+            ),
+          ),
+          cssWidgetMetadataRow(
+            cssWidgetMetadataName(t('Last updated:')),
+            cssWidgetMetadataValue(
+              lastUpdated ?
+                new Date(lastUpdated).toLocaleDateString('default', {
+                  month: 'long',
+                  day: 'numeric',
+                  year: 'numeric',
+                })
+                : t('(Missing info)'),
+              cssWidgetMetadataValue.cls('-missing', !lastUpdated),
+              testId('widget-last-updated'),
+            ),
+          ),
+          testId('widget-metadata'),
+        ),
+        variant !== 'custom' ? null : cssCustomUrlInput(
+          this._customUrl,
+          {placeholder: t('Widget URL')},
+          testId('custom-url'),
+        ),
+      ),
+      cssWidget.cls('-selected', use => id === use(this._selectedWidgetId)),
+      dom.on('click', () => this._selectedWidgetId.set(id)),
+      testId('widget'),
+      testId(`widget-${variant}`),
+    );
+  }
+
+  private async _save() {
+    if (this._saveDisabled.get()) { return; }
+
+    await this._saveSelectedWidget();
+    this._ctl.close();
+  }
+
+  private async _deselectOrClose() {
+    if (this._selectedWidgetId.get()) {
+      this._selectedWidgetId.set(null);
+    } else {
+      this._ctl.close();
+    }
+  }
+
+  private async _saveSelectedWidget() {
+    await this._gristDoc.docData.bundleActions(
+      'Save selected custom widget',
+      async () => {
+        let section = this._section;
+        if (!section) {
+          const {addWidget} = this._options;
+          if (!addWidget) {
+            throw new Error('Cannot add custom widget: missing `addWidget` implementation');
+          }
+
+          const {sectionRef} = await addWidget();
+          const newSection = this._gristDoc.docModel.viewSections.getRowModel(sectionRef);
+          if (!newSection.id.peek()) {
+            throw new Error(`Section ${sectionRef} does not exist`);
+          }
+          section = newSection;
+        }
+        const selectedWidgetId = this._selectedWidgetId.get();
+        if (selectedWidgetId === CUSTOM_URL_WIDGET_ID) {
+          return this._saveCustomUrlWidget(section);
+        } else {
+          return this._saveRemoteWidget(section);
+        }
+      }
+    );
+  }
+
+  private async _saveCustomUrlWidget(section: ViewSectionRec) {
+    bundleChanges(() => {
+      section.customDef.renderAfterReady(false);
+      section.customDef.url(this._customUrl.get());
+      section.customDef.widgetId(null);
+      section.customDef.widgetDef(null);
+      section.customDef.pluginId('');
+      section.customDef.access(AccessLevel.none);
+      section.customDef.widgetOptions(null);
+      section.hasCustomOptions(false);
+      section.customDef.columnsMapping(null);
+      section.columnsToMap(null);
+      section.desiredAccessLevel(AccessLevel.none);
+    });
+    await section.saveCustomDef();
+  }
+
+  private async _saveRemoteWidget(section: ViewSectionRec) {
+    const [pluginId, widgetId] = this._selectedWidgetId.get()!.split(':');
+    const {customDef} = section;
+    if (customDef.pluginId.peek() === pluginId && customDef.widgetId.peek() === widgetId) {
+      return;
+    }
+
+    const selectedWidget = matchWidget(this._widgets.get() ?? [], {widgetId, pluginId});
+    if (!selectedWidget) {
+      throw new Error(`Widget ${this._selectedWidgetId.get()} not found`);
+    }
+
+    bundleChanges(() => {
+      section.customDef.renderAfterReady(selectedWidget.renderAfterReady ?? false);
+      section.customDef.access(AccessLevel.none);
+      section.desiredAccessLevel(selectedWidget.accessLevel ?? AccessLevel.none);
+      // Keep a record of the original widget definition.
+      // Don't rely on this much, since the document could
+      // have moved installation since, and widgets could be
+      // served from elsewhere.
+      section.customDef.widgetDef(selectedWidget);
+      section.customDef.widgetId(selectedWidget.widgetId);
+      section.customDef.pluginId(selectedWidget.source?.pluginId ?? '');
+      section.customDef.url(null);
+      section.customDef.widgetOptions(null);
+      section.hasCustomOptions(false);
+      section.customDef.columnsMapping(null);
+      section.columnsToMap(null);
+    });
+    await section.saveCustomDef();
+  }
+
+  private _maybeClearSelection(event: MouseEvent) {
+    const target = event.target as HTMLElement;
+    if (
+      !target.closest('.custom-widget') &&
+      !target.closest('button') &&
+      !target.closest('a') &&
+      !target.closest('input')
+    ) {
+      this._selectedWidgetId.set(null);
+    }
+  }
+}
+
+export function getWidgetName({name, source}: ICustomWidget) {
+  return source?.name ? `${name} (${source.name})` : name;
+}
+
+function getWidgetVariant({isGristLabsMaintained = false, widgetId}: ICustomWidget): WidgetVariant {
+  if (widgetId === CUSTOM_URL_WIDGET_ID) {
+    return 'custom';
+  } else if (isGristLabsMaintained) {
+    return 'grist';
+  } else {
+    return 'community';
+  }
+}
+
+function getWidgetId({source, widgetId}: ICustomWidget) {
+  if (widgetId === CUSTOM_URL_WIDGET_ID) {
+    return CUSTOM_URL_WIDGET_ID;
+  } else {
+    return `${source?.pluginId ?? ''}:${widgetId}`;
+  }
+}
+
+function getWidgetCleanText({name, description, authors = []}: ICustomWidget) {
+  let cleanText = name;
+  if (description) { cleanText += ` ${description}`; }
+  if (authors[0]) { cleanText += ` ${authors[0].name}`; }
+  return cleanText;
+}
+
+export const cssWidgetMetadata = styled('div', `
+  margin-top: auto;
+  display: flex;
+  flex-direction: column;
+  row-gap: 4px;
+`);
+
+export const cssWidgetMetadataRow = styled('div', `
+  display: flex;
+  column-gap: 4px;
+`);
+
+export const cssWidgetMetadataName = styled('span', `
+  color: ${theme.lightText};
+  font-weight: 600;
+`);
+
+export const cssWidgetMetadataValue = styled('div', `
+  &-missing {
+    color: ${theme.lightText};
+  }
+`);
+
+export const cssDeveloperLink = styled(cssLink, `
+  font-weight: 600;
+`);
+
+const cssCustomWidgetGallery = styled('div', `
+  display: flex;
+  flex-direction: column;
+  width: 100%;
+  height: 100%;
+  outline: none;
+`);
+
+const WIDGET_WIDTH_PX = 240;
+
+const WIDGETS_GAP_PX = 16;
+
+const cssHeader = styled('div', `
+  display: flex;
+  column-gap: 16px;
+  row-gap: 8px;
+  flex-wrap: wrap;
+  justify-content: space-between;
+  margin: 40px 40px 16px 40px;
+
+  /* Don't go beyond the final grid column. */
+  max-width: ${(3 * WIDGET_WIDTH_PX) + (2 * WIDGETS_GAP_PX)}px;
+`);
+
+const cssTitle = styled('div', `
+  font-size: 24px;
+  font-weight: 500;
+  line-height: 32px;
+`);
+
+const cssSearchInputWrapper = styled('div', `
+  position: relative;
+  display: flex;
+  align-items: center;
+`);
+
+const cssSearchIcon = styled(icon, `
+  margin-left: 8px;
+  position: absolute;
+  --icon-color: ${theme.accentIcon};
+`);
+
+const cssSearchInput = styled(textInput, `
+  height: 28px;
+  padding-left: 32px;
+`);
+
+const cssShadowScroll = styled('div', `
+  display: flex;
+  flex-direction: column;
+  flex: unset;
+  flex-grow: 1;
+  padding: 16px 40px;
+`);
+
+const cssCenteredFlexGrow = styled('div', `
+  flex-grow: 1;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+`);
+
+const cssLoadingSpinner = cssCenteredFlexGrow;
+
+const cssNoMatchingWidgets = styled(cssCenteredFlexGrow, `
+  color: ${theme.lightText};
+`);
+
+const cssWidgets = styled('div', `
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(0px, ${WIDGET_WIDTH_PX}px));
+  gap: ${WIDGETS_GAP_PX}px;
+`);
+
+const cssWidget = styled('div', `
+  display: flex;
+  flex-direction: column;
+  box-shadow: 1px 1px 4px 1px ${theme.widgetGalleryShadow};
+  border-radius: 4px;
+  min-height: 183.5px;
+  cursor: pointer;
+
+  &:hover {
+    background-color: ${theme.widgetGalleryBgHover};
+  }
+  &-selected {
+    outline: 2px solid ${theme.widgetGalleryBorderSelected};
+    outline-offset: -2px;
+  }
+`);
+
+const cssWidgetHeader = styled('div', `
+  flex-shrink: 0;
+  border: 2px solid ${theme.widgetGalleryBorder};
+  border-bottom: 1px solid ${theme.widgetGalleryBorder};
+  border-radius: 4px 4px 0px 0px;
+  color: ${theme.lightText};
+  font-size: 10px;
+  line-height: 16px;
+  font-weight: 500;
+  padding: 4px 18px;
+  text-transform: uppercase;
+
+  &-secondary {
+    border: 0px;
+    color: ${theme.widgetGallerySecondaryHeaderFg};
+    background-color: ${theme.widgetGallerySecondaryHeaderBg};
+  }
+  .${cssWidget.className}:hover &-secondary {
+    background-color: ${theme.widgetGallerySecondaryHeaderBgHover};
+  }
+`);
+
+const cssWidgetBody = styled('div', `
+  display: flex;
+  flex-direction: column;
+  flex-grow: 1;
+  border: 2px solid ${theme.widgetGalleryBorder};
+  border-top: 0px;
+  border-radius: 0px 0px 4px 4px;
+  padding: 16px;
+`);
+
+const cssWidgetName = styled('div', `
+  font-size: 15px;
+  font-weight: 600;
+  margin-bottom: 16px;
+`);
+
+const cssWidgetDescription = styled('div', `
+  margin-bottom: 24px;
+
+  &-missing {
+    color: ${theme.lightText};
+  }
+`);
+
+const cssCustomUrlInput = styled(textInput, `
+  height: 28px;
+`);
+
+const cssHelpLink = styled(cssLink, `
+  display: inline-flex;
+  align-items: center;
+  column-gap: 8px;
+`);
+
+const cssHelpIcon = styled(icon, `
+  flex-shrink: 0;
+`);
+
+const cssFooter = styled('div', `
+  flex-shrink: 0;
+  display: flex;
+  flex-wrap: wrap;
+  justify-content: space-between;
+  align-items: center;
+  gap: 16px;
+  padding: 16px 40px;
+  border-top: 1px solid ${theme.widgetGalleryBorder};
+`);
+
+const cssFooterButtons = styled('div', `
+  display: flex;
+  column-gap: 8px;
+`);
+
+const cssModal = styled('div', `
+  width: 100%;
+  height: 100%;
+  max-width: 930px;
+  max-height: 623px;
+  padding: 0px;
+`);
+
+const cssTooltipIcon = styled('div', `
+  color: ${theme.widgetGallerySecondaryHeaderFg};
+  border-color: ${theme.widgetGallerySecondaryHeaderFg};
+`);
diff --git a/app/client/ui/GristTooltips.ts b/app/client/ui/GristTooltips.ts
index e212987d..b3f0c845 100644
--- a/app/client/ui/GristTooltips.ts
+++ b/app/client/ui/GristTooltips.ts
@@ -42,7 +42,8 @@ export type Tooltip =
   | 'formulaColumn'
   | 'accessRulesTableWide'
   | 'setChoiceDropdownCondition'
-  | 'setRefDropdownCondition';
+  | 'setRefDropdownCondition'
+  | 'communityWidgets';
 
 export type TooltipContentFunc = (...domArgs: DomElementArg[]) => DomContents;
 
@@ -152,6 +153,15 @@ see or edit which parts of your document.')
     ),
     ...args,
   ),
+  communityWidgets: (...args: DomElementArg[]) => cssTooltipContent(
+    dom('div',
+      t('Community widgets are created and maintained by Grist community members.')
+    ),
+    dom('div',
+      cssLink({href: commonUrls.helpCustomWidgets, target: '_blank'}, t('Learn more.')),
+    ),
+    ...args,
+  ),
 };
 
 export interface BehavioralPromptContent {
@@ -307,20 +317,6 @@ to determine who can see or edit which parts of your document.')),
     forceShow: true,
     markAsSeen: false,
   },
-  customURL: {
-    popupType: 'tip',
-    title: () => t('Custom Widgets'),
-    content: (...args: DomElementArg[]) => cssTooltipContent(
-      dom('div',
-        t(
-          'You can choose from widgets available to you in the dropdown, or embed your own by providing its full URL.'
-        ),
-      ),
-      dom('div', cssLink({href: commonUrls.helpCustomWidgets, target: '_blank'}, t('Learn more.'))),
-      ...args,
-    ),
-    deploymentTypes: ['saas', 'core', 'enterprise', 'electron'],
-  },
   calendarConfig: {
     popupType: 'tip',
     title: () => t('Calendar'),
diff --git a/app/client/ui/PredefinedCustomSectionConfig.ts b/app/client/ui/PredefinedCustomSectionConfig.ts
index b31bbdc4..1b690f08 100644
--- a/app/client/ui/PredefinedCustomSectionConfig.ts
+++ b/app/client/ui/PredefinedCustomSectionConfig.ts
@@ -1,6 +1,7 @@
-import {GristDoc} from "../components/GristDoc";
-import {ViewSectionRec} from "../models/entities/ViewSectionRec";
-import {CustomSectionConfig} from "./CustomSectionConfig";
+import {GristDoc} from 'app/client/components/GristDoc';
+import {ViewSectionRec} from 'app/client/models/entities/ViewSectionRec';
+import {CustomSectionConfig} from 'app/client/ui/CustomSectionConfig';
+import {ICustomWidget} from 'app/common/CustomWidget';
 
 export class PredefinedCustomSectionConfig extends CustomSectionConfig {
 
@@ -17,7 +18,7 @@ export class PredefinedCustomSectionConfig extends CustomSectionConfig {
     return false;
   }
 
-  protected async _getWidgets(): Promise<void> {
-    // Do nothing.
+  protected async _getWidgets(): Promise<ICustomWidget[]> {
+    return [];
   }
 }
diff --git a/app/client/ui/RightPanel.ts b/app/client/ui/RightPanel.ts
index a0f90bb5..f94dbaba 100644
--- a/app/client/ui/RightPanel.ts
+++ b/app/client/ui/RightPanel.ts
@@ -29,6 +29,7 @@ import {logTelemetryEvent} from 'app/client/lib/telemetry';
 import {reportError} from 'app/client/models/AppModel';
 import {ColumnRec, ViewSectionRec} from 'app/client/models/DocModel';
 import {CustomSectionConfig} from 'app/client/ui/CustomSectionConfig';
+import {showCustomWidgetGallery} from 'app/client/ui/CustomWidgetGallery';
 import {buildDescriptionConfig} from 'app/client/ui/DescriptionConfig';
 import {BuildEditorOptions} from 'app/client/ui/FieldConfig';
 import {GridOptions} from 'app/client/ui/GridOptions';
@@ -526,7 +527,7 @@ export class RightPanel extends Disposable {
       dom.maybe((use) => use(this._pageWidgetType) === 'custom', () => {
         const parts = vct._buildCustomTypeItems() as any[];
         return [
-          cssLabel(t("CUSTOM")),
+          cssSeparator(),
           // If 'customViewPlugin' feature is on, show the toggle that allows switching to
           // plugin mode. Note that the default mode for a new 'custom' view is 'url', so that's
           // the only one that will be shown without the feature flag.
@@ -880,13 +881,20 @@ export class RightPanel extends Disposable {
 
   private _createPageWidgetPicker(): DomElementMethod {
     const gristDoc = this._gristDoc;
-    const section = gristDoc.viewModel.activeSection;
-    const onSave = (val: IPageWidget) => gristDoc.saveViewSection(section.peek(), val);
-    return (elem) => { attachPageWidgetPicker(elem, gristDoc, onSave, {
-      buttonLabel:  t("Save"),
-      value: () => toPageWidget(section.peek()),
-      selectBy: (val) => gristDoc.selectBy(val),
-    }); };
+    const {activeSection} = gristDoc.viewModel;
+    const onSave = async (val: IPageWidget) => {
+      const {id} = await gristDoc.saveViewSection(activeSection.peek(), val);
+      if (val.type === 'custom') {
+        showCustomWidgetGallery(gristDoc, {sectionRef: id()});
+      }
+    };
+    return (elem) => {
+      attachPageWidgetPicker(elem, gristDoc, onSave, {
+        buttonLabel:  t("Save"),
+        value: () => toPageWidget(activeSection.peek()),
+        selectBy: (val) => gristDoc.selectBy(val),
+      });
+    };
   }
 
   // Returns dom for a section item.
diff --git a/app/client/ui/shadowScroll.ts b/app/client/ui/shadowScroll.ts
index 3610cff6..89587234 100644
--- a/app/client/ui/shadowScroll.ts
+++ b/app/client/ui/shadowScroll.ts
@@ -38,7 +38,8 @@ function isAtScrollTop(elem: Element): boolean {
 // Indicates that an element is currently scrolled such that the bottom of the element is visible.
 // It is expected that the elem arg has the offsetHeight property set.
 function isAtScrollBtm(elem: HTMLElement): boolean {
-  return elem.scrollTop >= (elem.scrollHeight - elem.offsetHeight);
+  // Check we're within a threshold of 1 pixel, to account for possible rounding.
+  return (elem.scrollHeight - elem.offsetHeight - elem.scrollTop) < 1;
 }
 
 const cssScrollMenu = styled('div', `
diff --git a/app/client/ui2018/IconList.ts b/app/client/ui2018/IconList.ts
index d58f39d6..39bbf15d 100644
--- a/app/client/ui2018/IconList.ts
+++ b/app/client/ui2018/IconList.ts
@@ -119,6 +119,7 @@ export type IconName = "ChartArea" |
   "Public" |
   "PublicColor" |
   "PublicFilled" |
+  "Question" |
   "Redo" |
   "Remove" |
   "RemoveBig" |
@@ -280,6 +281,7 @@ export const IconList: IconName[] = ["ChartArea",
   "Public",
   "PublicColor",
   "PublicFilled",
+  "Question",
   "Redo",
   "Remove",
   "RemoveBig",
diff --git a/app/client/ui2018/cssVars.ts b/app/client/ui2018/cssVars.ts
index 5cd706e5..dcac5402 100644
--- a/app/client/ui2018/cssVars.ts
+++ b/app/client/ui2018/cssVars.ts
@@ -471,6 +471,10 @@ export const theme = {
     undefined, colors.mediumGreyOpaque),
   rightPanelFieldSettingsButtonBg: new CustomProp('theme-right-panel-field-settings-button-bg',
     undefined, 'lightgrey'),
+  rightPanelCustomWidgetButtonFg: new CustomProp('theme-right-panel-custom-widget-button-fg',
+    undefined, colors.dark),
+  rightPanelCustomWidgetButtonBg: new CustomProp('theme-right-panel-custom-widget-button-bg',
+    undefined, colors.darkGrey),
 
   /* Document History */
   documentHistorySnapshotFg: new CustomProp('theme-document-history-snapshot-fg', undefined,
@@ -877,6 +881,20 @@ export const theme = {
 
   /* Numeric Spinners */
   numericSpinnerFg: new CustomProp('theme-numeric-spinner-fg', undefined, '#606060'),
+
+  /* Custom Widget Gallery */
+  widgetGalleryBorder: new CustomProp('theme-widget-gallery-border', undefined, colors.darkGrey),
+  widgetGalleryBorderSelected: new CustomProp('theme-widget-gallery-border-selected', undefined,
+    colors.lightGreen),
+  widgetGalleryShadow: new CustomProp('theme-widget-gallery-shadow', undefined, '#0000001A'),
+  widgetGalleryBgHover: new CustomProp('theme-widget-gallery-bg-hover', undefined,
+    colors.lightGrey),
+  widgetGallerySecondaryHeaderFg: new CustomProp('theme-widget-gallery-secondary-header-fg',
+    undefined, colors.light),
+  widgetGallerySecondaryHeaderBg: new CustomProp('theme-widget-gallery-secondary-header-bg',
+    undefined, colors.slate),
+  widgetGallerySecondaryHeaderBgHover: new CustomProp(
+    'theme-widget-gallery-secondary-header-bg-hover', undefined, '#7E7E85'),
 };
 
 const cssColors = values(colors).map(v => v.decl()).join('\n');
diff --git a/app/common/CustomWidget.ts b/app/common/CustomWidget.ts
index dc091e16..3bb70164 100644
--- a/app/common/CustomWidget.ts
+++ b/app/common/CustomWidget.ts
@@ -30,12 +30,10 @@ export interface ICustomWidget {
    * applying the Grist theme.
    */
   renderAfterReady?: boolean;
-
   /**
    * If set to false, do not offer to user in UI.
    */
   published?: boolean;
-
   /**
    * If the widget came from a plugin, we track that here.
    */
@@ -43,6 +41,29 @@ export interface ICustomWidget {
     pluginId: string;
     name: string;
   };
+  /**
+   * Widget description.
+   */
+  description?: string;
+  /**
+   * Widget authors.
+   *
+   * The first author is the one shown in the UI.
+   */
+  authors?: WidgetAuthor[];
+  /**
+   * Date the widget was last updated.
+   */
+  lastUpdatedAt?: string;
+  /**
+   * If the widget is maintained by Grist Labs.
+   */
+  isGristLabsMaintained?: boolean;
+}
+
+export interface WidgetAuthor {
+  name: string;
+  url?: string;
 }
 
 /**
diff --git a/app/common/Prefs.ts b/app/common/Prefs.ts
index 0ca63d0f..f7863924 100644
--- a/app/common/Prefs.ts
+++ b/app/common/Prefs.ts
@@ -86,10 +86,10 @@ export const BehavioralPrompt = StringUnion(
   'editCardLayout',
   'addNew',
   'rickRow',
-  'customURL',
   'calendarConfig',
 
   // The following were used in the past and should not be re-used.
+  // 'customURL',
   // 'formsAreHere',
 );
 export type BehavioralPrompt = typeof BehavioralPrompt.type;
diff --git a/app/common/ThemePrefs-ti.ts b/app/common/ThemePrefs-ti.ts
index 4a726118..09d4add9 100644
--- a/app/common/ThemePrefs-ti.ts
+++ b/app/common/ThemePrefs-ti.ts
@@ -211,6 +211,8 @@ export const ThemeColors = t.iface([], {
   "right-panel-toggle-button-disabled-bg": "string",
   "right-panel-field-settings-bg": "string",
   "right-panel-field-settings-button-bg": "string",
+  "right-panel-custom-widget-button-fg": "string",
+  "right-panel-custom-widget-button-bg": "string",
   "document-history-snapshot-fg": "string",
   "document-history-snapshot-selected-fg": "string",
   "document-history-snapshot-bg": "string",
@@ -438,6 +440,13 @@ export const ThemeColors = t.iface([], {
   "scroll-shadow": "string",
   "toggle-checkbox-fg": "string",
   "numeric-spinner-fg": "string",
+  "widget-gallery-border": "string",
+  "widget-gallery-border-selected": "string",
+  "widget-gallery-shadow": "string",
+  "widget-gallery-bg-hover": "string",
+  "widget-gallery-secondary-header-fg": "string",
+  "widget-gallery-secondary-header-bg": "string",
+  "widget-gallery-secondary-header-bg-hover": "string",
 });
 
 const exportedTypeSuite: t.ITypeSuite = {
diff --git a/app/common/ThemePrefs.ts b/app/common/ThemePrefs.ts
index 695299fc..03f45784 100644
--- a/app/common/ThemePrefs.ts
+++ b/app/common/ThemePrefs.ts
@@ -269,6 +269,8 @@ export interface ThemeColors {
   'right-panel-toggle-button-disabled-bg': string;
   'right-panel-field-settings-bg': string;
   'right-panel-field-settings-button-bg': string;
+  'right-panel-custom-widget-button-fg': string;
+  'right-panel-custom-widget-button-bg': string;
 
   /* Document History */
   'document-history-snapshot-fg': string;
@@ -572,6 +574,15 @@ export interface ThemeColors {
 
   /* Numeric Spinners */
   'numeric-spinner-fg': string;
+
+  /* Custom Widget Gallery */
+  'widget-gallery-border': string;
+  'widget-gallery-border-selected': string;
+  'widget-gallery-shadow': string;
+  'widget-gallery-bg-hover': string;
+  'widget-gallery-secondary-header-fg': string;
+  'widget-gallery-secondary-header-bg': string;
+  'widget-gallery-secondary-header-bg-hover': string;
 }
 
 export const ThemePrefsChecker = createCheckers(ThemePrefsTI).ThemePrefs as CheckerT<ThemePrefs>;
diff --git a/app/common/gristUrls.ts b/app/common/gristUrls.ts
index 6edc6fa8..e8410e1d 100644
--- a/app/common/gristUrls.ts
+++ b/app/common/gristUrls.ts
@@ -759,7 +759,8 @@ export interface GristLoadConfig {
   // List of registered plugins (used by HomePluginManager and DocPluginManager)
   plugins?: LocalPlugin[];
 
-  // If custom widget list is available.
+  // If additional custom widgets (besides the Custom URL widget) should be shown in
+  // the custom widget gallery.
   enableWidgetRepository?: boolean;
 
   // Whether there is somewhere for survey data to go.
diff --git a/app/common/themes/GristDark.ts b/app/common/themes/GristDark.ts
index fcde2bc9..5c64906a 100644
--- a/app/common/themes/GristDark.ts
+++ b/app/common/themes/GristDark.ts
@@ -248,6 +248,8 @@ export const GristDark: ThemeColors = {
   'right-panel-toggle-button-disabled-bg': '#32323F',
   'right-panel-field-settings-bg': '#404150',
   'right-panel-field-settings-button-bg': '#646473',
+  'right-panel-custom-widget-button-fg': '#EFEFEF',
+  'right-panel-custom-widget-button-bg': '#60606D',
 
   /* Document History */
   'document-history-snapshot-fg': '#EFEFEF',
@@ -551,4 +553,13 @@ export const GristDark: ThemeColors = {
 
   /* Numeric Spinners */
   'numeric-spinner-fg': '#A4A4B1',
+
+  /* Custom Widget Gallery */
+  'widget-gallery-border': '#555563',
+  'widget-gallery-border-selected': '#17B378',
+  'widget-gallery-shadow': '#00000080',
+  'widget-gallery-bg-hover': '#262633',
+  'widget-gallery-secondary-header-fg': '#FFFFFF',
+  'widget-gallery-secondary-header-bg': '#70707D',
+  'widget-gallery-secondary-header-bg-hover': '#60606D',
 };
diff --git a/app/common/themes/GristLight.ts b/app/common/themes/GristLight.ts
index e871e957..60d1193c 100644
--- a/app/common/themes/GristLight.ts
+++ b/app/common/themes/GristLight.ts
@@ -248,6 +248,8 @@ export const GristLight: ThemeColors = {
   'right-panel-toggle-button-disabled-bg': '#E8E8E8',
   'right-panel-field-settings-bg': '#E8E8E8',
   'right-panel-field-settings-button-bg': 'lightgrey',
+  'right-panel-custom-widget-button-fg': '#262633',
+  'right-panel-custom-widget-button-bg': '#D9D9D9',
 
   /* Document History */
   'document-history-snapshot-fg': '#262633',
@@ -551,4 +553,13 @@ export const GristLight: ThemeColors = {
 
   /* Numeric Spinners */
   'numeric-spinner-fg': '#606060',
+
+  /* Custom Widget Gallery */
+  'widget-gallery-border': '#D9D9D9',
+  'widget-gallery-border-selected': '#16B378',
+  'widget-gallery-shadow': '#0000001A',
+  'widget-gallery-bg-hover': '#F7F7F7',
+  'widget-gallery-secondary-header-fg': '#FFFFFF',
+  'widget-gallery-secondary-header-bg': '#929299',
+  'widget-gallery-secondary-header-bg-hover': '#7E7E85',
 };
diff --git a/static/icons/icons.css b/static/icons/icons.css
index 5159b32d..f4851513 100644
--- a/static/icons/icons.css
+++ b/static/icons/icons.css
@@ -120,6 +120,7 @@
   --icon-Public: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIj48cGF0aCBkPSJNMCAwSDE2VjE2SDB6Ii8+PGcgc3Ryb2tlPSIjMTZCMzc4IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwYXRoIGQ9Ik05Ljc3NSAxMS4yOTNMNy4zMTIgMTAuNTkzQzcuMTIyMjE2NjEgMTAuNTM4OTUxMSA2Ljk4MTk3MzAyIDEwLjM3ODMzNyA2Ljk1NCAxMC4xODNMNi43NDcgOC43MjZDNy44MTQxMjAyNiA4LjIzODMwNjE1IDguNDk5MDAyMDkgNy4xNzMyODE3IDguNSA2TDguNSA0LjYyNkM4LjUyNTE5NzU2IDIuOTczMDAzMjUgNy4yNDI1MDU0NyAxLjU5NDE1MzM3IDUuNTkyIDEuNSA0Ljc4MDUzMjcgMS40NzUxMDMyIDMuOTkzNjIwMiAxLjc4MDE0ODI3IDMuNDEwOTUzMjMgMi4zNDU0Nzg0NiAyLjgyODI4NjI1IDIuOTEwODA4NjQgMi40OTk2MTgxNiAzLjY4ODE1MDk1IDIuNSA0LjVMMi41IDZDMi41MDA5OTc5MSA3LjE3MzI4MTcgMy4xODU4Nzk3NCA4LjIzODMwNjE1IDQuMjUzIDguNzI2TDQuMDQ2IDEwLjE3OUM0LjAxODAyNjk4IDEwLjM3NDMzNyAzLjg3Nzc4MzM5IDEwLjUzNDk1MTEgMy42ODggMTAuNTg5TDEuMjI1IDExLjI4OUMuNzk1OTk1Njg4IDExLjQxMTcwNzIuNTAwMTk4MjMgMTEuODAzNzkxOC41IDEyLjI1TC41IDE0LjUgMTAuNSAxNC41IDEwLjUgMTIuMjU0QzEwLjQ5OTgwMTggMTEuODA3NzkxOCAxMC4yMDQwMDQzIDExLjQxNTcwNzIgOS43NzUgMTEuMjkzek0xMi41IDE0LjVMMTUuNSAxNC41IDE1LjUgMTEuMjgxQzE1LjQ5OTk4NzkgMTAuODIyMzIwNiAxNS4xODc5MzExIDEwLjQyMjQ1OTEgMTQuNzQzIDEwLjMxMUwxMS44MjYgOS41ODJDMTEuNjI4NDcxIDkuNTMyNzA4NDEgMTEuNDgwNTUyNCA5LjM2ODU3NDEgMTEuNDUyIDkuMTY3TDExLjI0NyA3LjcyNkMxMi4zMTQxMjAzIDcuMjM4MzA2MTUgMTIuOTk5MDAyMSA2LjE3MzI4MTcgMTMgNUwxMyAzLjYyNkMxMy4wMjUxOTc2IDEuOTczMDAzMjUgMTEuNzQyNTA1NS41OTQxNTMzNjUgMTAuMDkyLjUgOS41MzQ0NjYxNC40ODI3MzcyNzIgOC45ODMxNjAzOS42MjEyNTYzMDYgOC41LjkiLz48L2c+PC9nPjwvc3ZnPg==');
   --icon-PublicColor: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMTQiIHdpZHRoPSIxNiI+PGcgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIj48cGF0aCBkPSJNMCAwSDE2VjE2SDB6IiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgwIC0xKSIvPjxnIG9wYWNpdHk9Ii43ODMiPjxwYXRoIGQ9Ik0gMTUuMjc1LDEwLjI5MyAxMy4wODcsOS42NjggQyAxMi43MjY0NzgsOS41NjQ5MTc4IDEyLjQ1MzkyLDkuMjY4ODEwNSAxMi4zODEsOC45MDEgTCAxMi4yNDcsOC4yMjYgQyAxMy4zMTQxMiw3LjczODMwNjEgMTMuOTk5MDAyLDYuNjczMjgxNyAxNCw1LjUgViA0LjEyNiBDIDE0LjAyNTE5OCwyLjQ3MzAwMzMgMTIuNzQyNTA2LDEuMDk0MTUzNCAxMS4wOTIsMSA5Ljg3OTM3MDksMC45NjM0MTA4MiA4Ljc2NDA2NTUsMS42NjA3NzI3IDguMjY2LDIuNzY3IDguNzQzMDQ3OCwzLjQ2MTI3ODUgOC45OTg5MTk4LDQuMjgzNjI0NyA5LDUuMTI2IFYgNi41IEMgOC45OTgyMDc3LDYuODYzNzcwNiA4Ljk0NjA0NTYsNy4yMjU1NDA0IDguODQ1LDcuNTc1IDkuMTA0MjMyNSw3Ljg0Njk0MjMgOS40MTIyMzk1LDguMDY3NzcxMSA5Ljc1Myw4LjIyNiBMIDkuNjE5LDguOSBDIDkuNTQ2MDc5Niw5LjI2NzgxMDUgOS4yNzM1MjE5LDkuNTYzOTE3OCA4LjkxMyw5LjY2NyBMIDguMDcsOS45MDggOS41NSwxMC4zMzEgYyAwLjg1Njk2NCwwLjI0NzU1NCAxLjQ0NzcwNiwxLjAzMDk5OSAxLjQ1LDEuOTIzIFYgMTQuNSBjIC0wLjAwMTcsMC4xNzA3MiAtMC4wMzI3OCwwLjMzOTg3MSAtMC4wOTIsMC41IEggMTUuNSBjIDAuMjc2MTQyLDAgMC41LC0wLjIyMzg1OCAwLjUsLTAuNSB2IC0zLjI0NiBjIC0xLjk4ZS00LC0wLjQ0NjIwOCAtMC4yOTU5OTYsLTAuODM4MjkzIC0wLjcyNSwtMC45NjEgeiIgZmlsbD0iI2U2YTExNyIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCAtMSkiLz48cGF0aCBkPSJNIDkuMjc1LDExLjI5MyA3LjA4NywxMC42NjggQyA2LjcyNjIwMjMsMTAuNTY0NzU0IDYuNDUzNTgzLDEwLjI2ODE5NCA2LjM4MSw5LjkgTCA2LjI0Nyw5LjIyNSBDIDcuMzEzNzkxNCw4LjczNzQ1NjkgNy45OTg2MTEyLDcuNjcyOTE5NSA4LDYuNSBWIDUuMTI2IEMgOC4wMjUxOTc2LDMuNDczMDAzMyA2Ljc0MjUwNTUsMi4wOTQxNTM0IDUuMDkyLDIgNC4yODA1MzI3LDEuOTc1MTAzMiAzLjQ5MzYyMDIsMi4yODAxNDgzIDIuOTEwOTUzMiwyLjg0NTQ3ODUgMi4zMjgyODYzLDMuNDEwODA4NiAxLjk5OTYxODIsNC4xODgxNTA5IDIsNSB2IDEuNSBjIDkuOTc5ZS00LDEuMTczMjgxNyAwLjY4NTg3OTcsMi4yMzgzMDYyIDEuNzUzLDIuNzI2IEwgMy42MTksOS45IEMgMy41NDYwOCwxMC4yNjc4MTEgMy4yNzM1MjE5LDEwLjU2MzkxOCAyLjkxMywxMC42NjcgTCAwLjcyNSwxMS4yOTIgQyAwLjI5NTYzOTI2LDExLjQxNDgwOSAtMi40ODE3NzE2ZS00LDExLjgwNzQyMSAwLDEyLjI1NCBWIDE0LjUgQyAwLDE0Ljc3NjE0MiAwLjIyMzg1NzYzLDE1IDAuNSwxNSBoIDkgQyA5Ljc3NjE0MjQsMTUgMTAsMTQuNzc2MTQyIDEwLDE0LjUgViAxMi4yNTQgQyA5Ljk5OTgwMTgsMTEuODA3NzkyIDkuNzA0MDA0MywxMS40MTU3MDcgOS4yNzUsMTEuMjkzIFoiIGZpbGw9IiNmZmYiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDAgLTEpIi8+PC9nPjwvZz48L3N2Zz4=');
   --icon-PublicFilled: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTQiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIj48cGF0aCBkPSJNMCAwSDE2VjE2SDB6IiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgwIC0xKSIvPjxnIGZpbGw9IiMxNkIzNzgiIGZpbGwtcnVsZT0ibm9uemVybyI+PHBhdGggZD0iTTE1LjI3NSwxMC4yOTMgTDEzLjA4Nyw5LjY2OCBDMTIuNzI2NDc4MSw5LjU2NDkxNzggMTIuNDUzOTIwNCw5LjI2ODgxMDUgMTIuMzgxLDguOTAxIEwxMi4yNDcsOC4yMjYgQzEzLjMxNDEyMDMsNy43MzgzMDYxNSAxMy45OTkwMDIxLDYuNjczMjgxNyAxNCw1LjUgTDE0LDQuMTI2IEMxNC4wMjUxOTc2LDIuNDczMDAzMjUgMTIuNzQyNTA1NSwxLjA5NDE1MzM3IDExLjA5MiwxIEM5Ljg3OTM3MDksMC45NjM0MTA4MjIgOC43NjQwNjU1LDEuNjYwNzcyNjkgOC4yNjYsMi43NjcgQzguNzQzMDQ3OCwzLjQ2MTI3ODUgOC45OTg5MTk4NCw0LjI4MzYyNDc0IDksNS4xMjYgTDksNi41IEM4Ljk5ODIwNzc0LDYuODYzNzcwNTcgOC45NDYwNDU1OCw3LjIyNTU0MDM4IDguODQ1LDcuNTc1IEM5LjEwNDIzMjUzLDcuODQ2OTQyMjYgOS40MTIyMzk1Myw4LjA2Nzc3MTA2IDkuNzUzLDguMjI2IEw5LjYxOSw4LjkgQzkuNTQ2MDc5NTcsOS4yNjc4MTA1IDkuMjczNTIxODcsOS41NjM5MTc4IDguOTEzLDkuNjY3IEw4LjA3LDkuOTA4IEw5LjU1LDEwLjMzMSBDMTAuNDA2OTY0MywxMC41Nzg1NTM2IDEwLjk5NzcwNTksMTEuMzYxOTk5MyAxMSwxMi4yNTQgTDExLDE0LjUgQzEwLjk5ODM0MjgsMTQuNjcwNzE5OCAxMC45NjcyMTg5LDE0LjgzOTg3MTUgMTAuOTA4LDE1IEwxNS41LDE1IEMxNS43NzYxNDI0LDE1IDE2LDE0Ljc3NjE0MjQgMTYsMTQuNSBMMTYsMTEuMjU0IEMxNS45OTk4MDE4LDEwLjgwNzc5MTggMTUuNzA0MDA0MywxMC40MTU3MDcyIDE1LjI3NSwxMC4yOTMgWiIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCAtMSkiLz48cGF0aCBkPSJNOS4yNzUsMTEuMjkzIEw3LjA4NywxMC42NjggQzYuNzI2MjAyMzIsMTAuNTY0NzUzOSA2LjQ1MzU4MywxMC4yNjgxOTM1IDYuMzgxLDkuOSBMNi4yNDcsOS4yMjUgQzcuMzEzNzkxNDEsOC43Mzc0NTY5MyA3Ljk5ODYxMTI1LDcuNjcyOTE5NTQgOCw2LjUgTDgsNS4xMjYgQzguMDI1MTk3NTYsMy40NzMwMDMyNSA2Ljc0MjUwNTQ3LDIuMDk0MTUzMzcgNS4wOTIsMiBDNC4yODA1MzI3LDEuOTc1MTAzMiAzLjQ5MzYyMDIsMi4yODAxNDgyNyAyLjkxMDk1MzIzLDIuODQ1NDc4NDYgQzIuMzI4Mjg2MjUsMy40MTA4MDg2NCAxLjk5OTYxODE2LDQuMTg4MTUwOTUgMiw1IEwyLDYuNSBDMi4wMDA5OTc5MSw3LjY3MzI4MTcgMi42ODU4Nzk3NCw4LjczODMwNjE1IDMuNzUzLDkuMjI2IEwzLjYxOSw5LjkgQzMuNTQ2MDc5NTcsMTAuMjY3ODEwNSAzLjI3MzUyMTg3LDEwLjU2MzkxNzggMi45MTMsMTAuNjY3IEwwLjcyNSwxMS4yOTIgQzAuMjk1NjM5MjYyLDExLjQxNDgwOTEgLTAuMDAwMjQ4MTc3MTU3LDExLjgwNzQyMTIgMCwxMi4yNTQgTDAsMTQuNSBDMy4zODE3NjU4MWUtMTcsMTQuNzc2MTQyNCAwLjIyMzg1NzYyNSwxNSAwLjUsMTUgTDkuNSwxNSBDOS43NzYxNDIzNywxNSAxMCwxNC43NzYxNDI0IDEwLDE0LjUgTDEwLDEyLjI1NCBDOS45OTk4MDE3NywxMS44MDc3OTE4IDkuNzA0MDA0MzEsMTEuNDE1NzA3MiA5LjI3NSwxMS4yOTMgWiIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCAtMSkiLz48L2c+PC9nPjwvc3ZnPg==');
+  --icon-Question: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGNpcmNsZSBjeD0iOCIgY3k9IjgiIHI9IjcuNSIgc3Ryb2tlPSIjMTZCMzc4Ii8+PHBhdGggZD0iTTcuMTI0NiAxMC4zNDA5VjEwLjI4NTVDNy4xMzA3NiA5LjY5NzY4IDcuMTkyMzEgOS4yMjk4OCA3LjMwOTI2IDguODgyMUM3LjQyNjIxIDguNTM0MzMgNy41OTI0IDguMjUyNzIgNy44MDc4NCA4LjAzNzI5QzguMDIzMjcgNy44MjE4NSA4LjI4MTggNy42MjMzNCA4LjU4MzQxIDcuNDQxNzZDOC43NjQ5OSA3LjMzMDk3IDguOTI4MSA3LjIwMDE3IDkuMDcyNzUgNy4wNDkzNkM5LjIxNzQgNi44OTU0OCA5LjMzMTI4IDYuNzE4NTEgOS40MTQzNyA2LjUxODQ3QzkuNTAwNTUgNi4zMTg0MiA5LjU0MzYzIDYuMDk2ODMgOS41NDM2MyA1Ljg1MzY5QzkuNTQzNjMgNS41NTIwOCA5LjQ3Mjg1IDUuMjkwNDggOS4zMzEyOCA1LjA2ODg5QzkuMTg5NyA0Ljg0NzMgOS4wMDA0MyA0LjY3NjQ5IDguNzYzNDUgNC41NTY0NkM4LjUyNjQ3IDQuNDM2NDMgOC4yNjMzMyA0LjM3NjQyIDcuOTc0MDMgNC4zNzY0MkM3LjcyMTY2IDQuMzc2NDIgNy40Nzg1MyA0LjQyODc0IDcuMjQ0NjMgNC41MzMzOEM3LjAxMDczIDQuNjM4MDIgNi44MTUzIDQuODAyNjggNi42NTgzNCA1LjAyNzM0QzYuNTAxMzggNS4yNTIwMSA2LjQxMDU5IDUuNTQ1OTMgNi4zODU5NiA1LjkwOTA5SDUuMjIyNjFDNS4yNDcyMyA1LjM4NTg5IDUuMzgyNjUgNC45MzgwOSA1LjYyODg2IDQuNTY1N0M1Ljg3ODE1IDQuMTkzMyA2LjIwNTkyIDMuOTA4NjIgNi42MTIxNyAzLjcxMTY1QzcuMDIxNSAzLjUxNDY4IDcuNDc1NDUgMy40MTYxOSA3Ljk3NDAzIDMuNDE2MTlDOC41MTU3IDMuNDE2MTkgOC45ODY1OCAzLjUyMzkxIDkuMzg2NjcgMy43MzkzNUM5Ljc4OTg1IDMuOTU0NzggMTAuMTAwNyA0LjI1MDI0IDEwLjMxOTIgNC42MjU3MUMxMC41NDA4IDUuMDAxMTggMTAuNjUxNiA1LjQyODk4IDEwLjY1MTYgNS45MDkwOUMxMC42NTE2IDYuMjQ3NjMgMTAuNTk5MyA2LjU1Mzg2IDEwLjQ5NDYgNi44Mjc3N0MxMC4zOTMxIDcuMTAxNjggMTAuMjQ1MyA3LjM0NjM1IDEwLjA1MTQgNy41NjE3OUM5Ljg2MDYzIDcuNzc3MjMgOS42Mjk4MSA3Ljk2ODA0IDkuMzU4OTggOC4xMzQyM0M5LjA4ODE0IDguMzAzNSA4Ljg3MTE3IDguNDgyMDEgOC43MDgwNSA4LjY2OTc0QzguNTQ0OTQgOC44NTQ0IDguNDI2NDUgOS4wNzQ0NiA4LjM1MjU4IDkuMzI5OUM4LjI3ODcyIDkuNTg1MzUgOC4yMzg3MSA5LjkwMzg4IDguMjMyNTUgMTAuMjg1NVYxMC4zNDA5SDcuMTI0NlpNNy43MTU1MSAxMy4wNzM5QzcuNDg3NzYgMTMuMDczOSA3LjI5MjMzIDEyLjk5MjMgNy4xMjkyMiAxMi44MjkyQzYuOTY2MSAxMi42NjYxIDYuODg0NTQgMTIuNDcwNiA2Ljg4NDU0IDEyLjI0MjlDNi44ODQ1NCAxMi4wMTUyIDYuOTY2MSAxMS44MTk3IDcuMTI5MjIgMTEuNjU2NkM3LjI5MjMzIDExLjQ5MzUgNy40ODc3NiAxMS40MTE5IDcuNzE1NTEgMTEuNDExOUM3Ljk0MzI2IDExLjQxMTkgOC4xMzg2OSAxMS40OTM1IDguMzAxOCAxMS42NTY2QzguNDY0OTIgMTEuODE5NyA4LjU0NjQ4IDEyLjAxNTIgOC41NDY0OCAxMi4yNDI5QzguNTQ2NDggMTIuMzkzNyA4LjUwODAxIDEyLjUzMjIgOC40MzEwNiAxMi42NTg0QzguMzU3MiAxMi43ODQ2IDguMjU3MTggMTIuODg2MSA4LjEzMDk5IDEyLjk2MzFDOC4wMDc4OSAxMy4wMzY5IDcuODY5MzkgMTMuMDczOSA3LjcxNTUxIDEzLjA3MzlaIiBmaWxsPSIjMTZCMzc4Ii8+PC9zdmc+');
   --icon-Redo: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTIuMzU1MzI4LDUgTDYuNTAwMTAyMzgsNSBDNi43NzYyNDQ3Niw1IDcuMDAwMTAyMzgsNS4yMjM4NTc2MyA3LjAwMDEwMjM4LDUuNSBDNy4wMDAxMDIzOCw1Ljc3NjE0MjM3IDYuNzc2MjQ0NzYsNiA2LjUwMDEwMjM4LDYgTDEuNTEwMjEyMzgsNiBDMS40ODY4NTQ0NCw2LjAwMDQ5MTk4IDEuNDYzMzU4OTcsNS45OTkzNDQxNCAxLjQzOTg5NjI3LDUuOTk2NDk5MjUgQzEuMzYwMzg0NTQsNS45ODY4NzA1OCAxLjI4NjYwNzQzLDUuOTU4NjY1NjMgMS4yMjMwNzYwNCw1LjkxNjMwNDg0IEMxLjA5OTMxODAxLDUuODM0MTc1NDcgMS4wMjE5NjcwOSw1LjcwMzQ3ODggMS4wMDQwMTY1Niw1LjU2Mjg2NjI5IEMxLjAwMTkxMjc3LDUuNTQ2MDk2MDIgMS4wMDA2Mzk1Niw1LjUyOTA2NzQgMS4wMDAyMzk0Miw1LjUxMTgyMjkyIEMwLjk5OTk1NjM3MSw1LjUwNDI3MzIxIDAuOTk5OTQyNjg0LDUuNDk2NzA0NiAxLjAwMDEwMjM4LDUuNDg5MTI2OTMgTDEuMDAwMTAyMzgsMC41IEMxLjAwMDEwMjM4LDAuMjIzODU3NjI1IDEuMjIzOTYwMDEsMCAxLjUwMDEwMjM4LDAgQzEuNzc2MjQ0NzYsMCAyLjAwMDEwMjM4LDAuMjIzODU3NjI1IDIuMDAwMTAyMzgsMC41IEwyLjAwMDEwMjM4LDMuNzc0NjkzMTYgQzMuMzY4NjQzMjgsMi4wMzk0MDM0MyA1LjMyOTE3Nzk4LDEgNy41MDAxMDIzOCwxIEMxMS42NDIyNDQ4LDEgMTUuMDAwMTAyNCw0LjM1Nzg1NzYzIDE1LjAwMDEwMjQsOC41IEMxNS4wMDAxMDI0LDEyLjY0MjE0MjQgMTEuNjQyMjQ0OCwxNiA3LjUwMDEwMjM4LDE2IEM3LjIyMzk2MDAxLDE2IDcuMDAwMTAyMzgsMTUuNzc2MTQyNCA3LjAwMDEwMjM4LDE1LjUgQzcuMDAwMTAyMzgsMTUuMjIzODU3NiA3LjIyMzk2MDAxLDE1IDcuNTAwMTAyMzgsMTUgQzExLjA4OTk2LDE1IDE0LjAwMDEwMjQsMTIuMDg5ODU3NiAxNC4wMDAxMDI0LDguNSBDMTQuMDAwMTAyNCw0LjkxMDE0MjM3IDExLjA4OTk2LDIgNy41MDAxMDIzOCwyIEM1LjQxNTg0ODkyLDIgMy41NDU2NTQwMiwzLjEzMDY0MDcgMi4zNTUzMjgsNSBaIiBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9Im5vbnplcm8iIHRyYW5zZm9ybT0ibWF0cml4KC0xIDAgMCAxIDE2IDApIi8+PC9zdmc+');
   --icon-Remove: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTYsNCBMNiwyLjUgQzYsMi4yMjM4NTc2MyA2LjIyMzg1NzYzLDIgNi41LDIgTDkuNSwyIEM5Ljc3NjE0MjM3LDIgMTAsMi4yMjM4NTc2MyAxMCwyLjUgTDEwLDQgTDEzLjUsNCBDMTMuNzc2MTQyNCw0IDE0LDQuMjIzODU3NjMgMTQsNC41IEMxNCw0Ljc3NjE0MjM3IDEzLjc3NjE0MjQsNSAxMy41LDUgTDIuNSw1IEMyLjIyMzg1NzYzLDUgMiw0Ljc3NjE0MjM3IDIsNC41IEMyLDQuMjIzODU3NjMgMi4yMjM4NTc2Myw0IDIuNSw0IEw2LDQgWiBNNyw0IEw5LDQgTDksMyBMNywzIEw3LDQgWiBNMTEsNi41IEMxMSw2LjIyMzg1NzYzIDExLjIyMzg1NzYsNiAxMS41LDYgQzExLjc3NjE0MjQsNiAxMiw2LjIyMzg1NzYzIDEyLDYuNSBMMTIsMTIuNSBDMTIsMTMuMzI4NDI3MSAxMS4zMjg0MjcxLDE0IDEwLjUsMTQgTDUuNSwxNCBDNC42NzE1NzI4OCwxNCA0LDEzLjMyODQyNzEgNCwxMi41IEw0LDYuNSBDNCw2LjIyMzg1NzYzIDQuMjIzODU3NjMsNiA0LjUsNiBDNC43NzYxNDIzNyw2IDUsNi4yMjM4NTc2MyA1LDYuNSBMNSwxMi41IEM1LDEyLjc3NjE0MjQgNS4yMjM4NTc2MywxMyA1LjUsMTMgTDEwLjUsMTMgQzEwLjc3NjE0MjQsMTMgMTEsMTIuNzc2MTQyNCAxMSwxMi41IEwxMSw2LjUgWiIgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIi8+PC9zdmc+');
   --icon-RemoveBig: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZD0iTSA1LjQ4MTQ3MTEsNCA1LjQ5ODQyMDMsMS4wNTkzMjIgQyA1LjUwMDAxMTksMC43ODMxODQyMiA1LjcyMTMyOTQsMC41NTkzMjIwMyA1Ljk5NjMwMTcsMC41NTkzMjIwMyBIIDEwLjE5ODkzIGMgMC4yNzQ5NzIsMCAwLjQ5OTQ3MiwwLjIyMzg2MjE5IDAuNDk3ODgxLDAuNDk5OTk5OTcgTCAxMC42Nzk4NjIsNCAxNS4zNTI5NzcsMy45ODMwNTA4IGMgMC4yNzQ5NzEsLTkuOTczZS00IDAuNDk3ODgxLDAuMjIzODU3NiAwLjQ5Nzg4MSwwLjUgMCwwLjI3NjE0MjQgLTAuMjIyOTA5LDAuNDk5NjgzNyAtMC40OTc4ODEsMC41IEwgMC42MTk3Nzc2OCw1IEMgMC4zNDQ4MDU1OCw1LjAwMDMxNjMgMC4xMjE4OTYzOSw0Ljc3NjE0MjQgMC4xMjE4OTYzOSw0LjUgYyAwLC0wLjI3NjE0MjQgMC4yMjI5MDksLTAuNSAwLjQ5Nzg4MTI5LC0wLjUgeiBNIDYuNDc3MjMzOCw0IEggOS42ODQwOTkgTCA5LjcwMTA0ODIsMS41NTkzMjIgSCA2LjQ5NDE4MyBaIG0gNS41Mjk4NDcyLDMuNjI2OTUxNCBjIC0zLjk4ZS00LC0wLjI3NjE0MjEgMC4yMjI5MDksLTAuNSAwLjQ5Nzg4MSwtMC41IDAuMjc0OTczLDAgMC40OTc0ODIsMC4yMjM4NTggMC40OTc4ODIsMC41IGwgMC4wMDkyLDYuMzQ2MjQwNiBjIDAuMDAxMiwwLjgyODQyNiAtMC42Njg3MjgsMS41IC0xLjQ5MzY0NCwxLjUgSCA0LjQ2MjQxMjYgYyAtMC44MjQ5MTY4LDAgLTEuNDkyNDQ5NiwtMC42NzE1NzQgLTEuNDkzNjQ0LC0xLjUgbCAtMC4wMDkxNSwtNi4zNDYyNDA2IGMgLTMuOTgxZS00LC0wLjI3NjE0MjEgMC4yMjI5MDkxLC0wLjUgMC40OTc4ODEzLC0wLjUgMC4yNzQ5NzIzLDAgMC40OTc0ODMzLDAuMjIzODU3OSAwLjQ5Nzg4MTQsMC41IGwgMC4wMDkxNSw2LjM0NjI0MDYgYyAzLjk4MWUtNCwwLjI3NjE0MSAwLjIyMjkwOTEsMC41IDAuNDk3ODgxMywwLjUgaCA3LjA1NTk0MDQgYyAwLjI3NDk3MiwwIDAuNDk4Mjc5LC0wLjIyMzg1OSAwLjQ5Nzg4MSwtMC41IHoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg==');
diff --git a/static/ui-icons/UI/Question.svg b/static/ui-icons/UI/Question.svg
new file mode 100644
index 00000000..5433502a
--- /dev/null
+++ b/static/ui-icons/UI/Question.svg
@@ -0,0 +1,4 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<circle cx="8" cy="8" r="7.5" stroke="#16B378"/>
+<path d="M7.1246 10.3409V10.2855C7.13076 9.69768 7.19231 9.22988 7.30926 8.8821C7.42621 8.53433 7.5924 8.25272 7.80784 8.03729C8.02327 7.82185 8.2818 7.62334 8.58341 7.44176C8.76499 7.33097 8.9281 7.20017 9.07275 7.04936C9.2174 6.89548 9.33128 6.71851 9.41437 6.51847C9.50055 6.31842 9.54363 6.09683 9.54363 5.85369C9.54363 5.55208 9.47285 5.29048 9.33128 5.06889C9.1897 4.8473 9.00043 4.67649 8.76345 4.55646C8.52647 4.43643 8.26333 4.37642 7.97403 4.37642C7.72166 4.37642 7.47853 4.42874 7.24463 4.53338C7.01073 4.63802 6.8153 4.80268 6.65834 5.02734C6.50138 5.25201 6.41059 5.54593 6.38596 5.90909H5.22261C5.24723 5.38589 5.38265 4.93809 5.62886 4.5657C5.87815 4.1933 6.20592 3.90862 6.61217 3.71165C7.0215 3.51468 7.47545 3.41619 7.97403 3.41619C8.5157 3.41619 8.98658 3.52391 9.38667 3.73935C9.78985 3.95478 10.1007 4.25024 10.3192 4.62571C10.5408 5.00118 10.6516 5.42898 10.6516 5.90909C10.6516 6.24763 10.5993 6.55386 10.4946 6.82777C10.3931 7.10168 10.2453 7.34635 10.0514 7.56179C9.86063 7.77723 9.62981 7.96804 9.35898 8.13423C9.08814 8.3035 8.87117 8.48201 8.70805 8.66974C8.54494 8.8544 8.42645 9.07446 8.35258 9.3299C8.27872 9.58535 8.23871 9.90388 8.23255 10.2855V10.3409H7.1246ZM7.71551 13.0739C7.48776 13.0739 7.29233 12.9923 7.12922 12.8292C6.9661 12.6661 6.88454 12.4706 6.88454 12.2429C6.88454 12.0152 6.9661 11.8197 7.12922 11.6566C7.29233 11.4935 7.48776 11.4119 7.71551 11.4119C7.94326 11.4119 8.13869 11.4935 8.3018 11.6566C8.46492 11.8197 8.54648 12.0152 8.54648 12.2429C8.54648 12.3937 8.50801 12.5322 8.43106 12.6584C8.3572 12.7846 8.25718 12.8861 8.13099 12.9631C8.00789 13.0369 7.86939 13.0739 7.71551 13.0739Z" fill="#16B378"/>
+</svg>
diff --git a/test/nbrowser/AttachedCustomWidget.ts b/test/nbrowser/AttachedCustomWidget.ts
index 34f2e7fe..3115edcb 100644
--- a/test/nbrowser/AttachedCustomWidget.ts
+++ b/test/nbrowser/AttachedCustomWidget.ts
@@ -24,13 +24,6 @@ describe('AttachedCustomWidget', function () {
   let widgetServerUrl = '';
   // Holds widgets manifest content.
   let widgets: ICustomWidget[] = [];
-  // Switches widget manifest url
-  async function useManifest(url: string) {
-    await server.testingHooks.setWidgetRepositoryUrl(url ? `${widgetServerUrl}${url}` : '');
-    await driver.executeAsyncScript(
-      (done: any) => (window as any).gristApp?.topAppModel.testReloadWidgets().then(done).catch(done) || done()
-    );
-  }
 
   async function buildWidgetServer(){
     // Create simple widget server that serves manifest.json file, some widgets and some error pages.
@@ -69,12 +62,11 @@ describe('AttachedCustomWidget', function () {
   before(async function () {
     await buildWidgetServer();
     oldEnv = new EnvironmentSnapshot();
+    process.env.GRIST_WIDGET_LIST_URL = `${widgetServerUrl}${manifestEndpoint}`;
     process.env.PERMITTED_CUSTOM_WIDGETS = "calendar";
     await server.restart();
-    await useManifest(manifestEndpoint);
     const session = await gu.session().login();
     await session.tempDoc(cleanup, 'Hello.grist');
-
   });
 
   after(async function () {
diff --git a/test/nbrowser/BehavioralPrompts.ts b/test/nbrowser/BehavioralPrompts.ts
index b8bda15a..a49b44da 100644
--- a/test/nbrowser/BehavioralPrompts.ts
+++ b/test/nbrowser/BehavioralPrompts.ts
@@ -145,18 +145,6 @@ describe('BehavioralPrompts', function() {
     await assertPromptTitle('Editing Card Layout');
   });
 
-  it('should be shown after adding custom view as a new page', async function() {
-    await gu.addNewPage('Custom', 'Table1');
-    await assertPromptTitle('Custom Widgets');
-    await gu.undo();
-  });
-
-  it('should be shown after adding custom section', async function() {
-    await gu.addNewSection('Custom', 'Table1');
-    await assertPromptTitle('Custom Widgets');
-    await gu.undo();
-  });
-
   describe('for the Add New button', function() {
     it('should not be shown if site is empty', async function() {
       session = await gu.session().user('user4').login({showTips: true});
diff --git a/test/nbrowser/CustomView.ts b/test/nbrowser/CustomView.ts
index ed32f62e..89de53fe 100644
--- a/test/nbrowser/CustomView.ts
+++ b/test/nbrowser/CustomView.ts
@@ -1,25 +1,12 @@
 import {safeJsonParse} from 'app/common/gutil';
+import * as chai from 'chai';
 import {assert, driver, Key} from 'mocha-webdriver';
+import {serveCustomViews, Serving, setAccess} from 'test/nbrowser/customUtil';
 import * as gu from 'test/nbrowser/gristUtils';
 import {server, setupTestSuite} from 'test/nbrowser/testUtils';
 
-import { serveCustomViews, Serving, setAccess } from 'test/nbrowser/customUtil';
-
-import * as chai from 'chai';
 chai.config.truncateThreshold = 5000;
 
-async function setCustomWidget() {
-  // if there is a select widget option
-  if (await driver.find('.test-config-widget-select').isPresent()) {
-    const selected = await driver.find('.test-config-widget-select .test-select-open').getText();
-    if (selected != "Custom URL") {
-      await driver.find('.test-config-widget-select .test-select-open').click();
-      await driver.findContent('.test-select-menu li', "Custom URL").click();
-      await gu.waitForServer();
-    }
-  }
-}
-
 describe('CustomView', function() {
   this.timeout(20000);
   gu.bigScreen();
@@ -49,9 +36,8 @@ describe('CustomView', function() {
     await gu.addNewSection('Custom', 'Table1');
 
     // Point to a widget that doesn't immediately call ready.
+    await gu.setCustomWidgetUrl(`${serving.url}/deferred-ready`, {openGallery: false});
     await gu.toggleSidePanel('right', 'open');
-    await driver.find('.test-config-widget-url').click();
-    await gu.sendKeys(`${serving.url}/deferred-ready`, Key.ENTER);
 
     // We should have a single iframe.
     assert.equal(await driver.findAll('iframe').then(f => f.length), 1);
@@ -108,10 +94,8 @@ describe('CustomView', function() {
 
         // Replace the widget with a custom widget that just reads out the data
         // as JSON.
-        await driver.find('.test-config-widget').click();
-        await setCustomWidget();
-        await driver.find('.test-config-widget-url').click();
-        await driver.sendKeys(`${serving.url}/readout`, Key.ENTER);
+        await gu.setCustomWidgetUrl(`${serving.url}/readout`, {openGallery: false});
+        await gu.openWidgetPanel();
         await setAccess(access);
         await gu.waitForServer();
 
@@ -167,10 +151,8 @@ describe('CustomView', function() {
         await gu.waitForServer();
 
         // Choose the custom view that just reads out data as json
-        await driver.find('.test-config-widget').click();
-        await setCustomWidget();
-        await driver.find('.test-config-widget-url').click();
-        await driver.sendKeys(`${serving.url}/readout`, Key.ENTER);
+        await gu.setCustomWidgetUrl(`${serving.url}/readout`, {openGallery: false});
+        await gu.openWidgetPanel();
         await setAccess(access);
         await gu.waitForServer();
 
@@ -265,7 +247,7 @@ describe('CustomView', function() {
       it('allows switching to custom section by clicking inside it', async function() {
         await gu.getCell({section: 'FRIENDS', col: 0, rowNum: 1}).click();
         assert.equal(await gu.getActiveSectionTitle(), 'FRIENDS');
-        assert.equal(await driver.find('.test-config-widget-url').isPresent(), false);
+        assert.equal(await driver.find('.test-config-widget-open-custom-widget-gallery').isPresent(), false);
 
         const iframe = gu.getSection('Friends custom').find('iframe');
         await driver.switchTo().frame(iframe);
@@ -274,24 +256,19 @@ describe('CustomView', function() {
         // Check that the right section is active, and its settings visible in the side panel.
         await driver.switchTo().defaultContent();
         assert.equal(await gu.getActiveSectionTitle(), 'FRIENDS Custom');
-        assert.equal(await driver.find('.test-config-widget-url').isPresent(), true);
+        assert.equal(await driver.find('.test-config-widget-open-custom-widget-gallery').isPresent(), true);
 
         // Switch back.
         await gu.getCell({section: 'FRIENDS', col: 0, rowNum: 1}).click();
         assert.equal(await gu.getActiveSectionTitle(), 'FRIENDS');
-        assert.equal(await driver.find('.test-config-widget-url').isPresent(), false);
+        assert.equal(await driver.find('.test-config-widget-open-custom-widget-gallery').isPresent(), false);
       });
 
       it('deals correctly with requests that require full access', async function() {
         // Choose a custom widget that tries to replace all cells in all user tables with 'zap'.
         await gu.getSection('Friends Custom').click();
-        await driver.find('.test-config-widget').click();
-        await setAccess("none");
-        await gu.waitForServer();
-
-        await gu.setValue(driver.find('.test-config-widget-url'), '');
-        await driver.find('.test-config-widget-url').click();
-        await driver.sendKeys(`${serving.url}/zap`, Key.ENTER);
+        await gu.setCustomWidgetUrl(`${serving.url}/zap`);
+        await gu.openWidgetPanel();
         await setAccess(access);
         await gu.waitForServer();
 
@@ -329,12 +306,10 @@ describe('CustomView', function() {
     // The test doc already has a Custom View widget. It just needs to
     // have a URL set.
     await gu.getSection('TYPES custom').click();
-    await driver.find('.test-config-widget').click();
-    await setCustomWidget();
+    await gu.setCustomWidgetUrl(`${serving.url}/types`);
     // If we needed to change widget to Custom URL, make sure access is read table.
     await setAccess("read table");
-    await driver.find('.test-config-widget-url').click();
-    await driver.sendKeys(`${serving.url}/types`, Key.ENTER);
+    await gu.waitForServer();
 
     const iframe = gu.getSection('TYPES custom').find('iframe');
     await driver.switchTo().frame(iframe);
@@ -480,10 +455,8 @@ describe('CustomView', function() {
     await gu.waitForServer();
 
     // Select a custom widget that tries to replace all cells in all user tables with 'zap'.
-    await driver.find('.test-config-widget').click();
-    await setCustomWidget();
-    await driver.find('.test-config-widget-url').click();
-    await driver.sendKeys(`${serving.url}/zap`, Key.ENTER);
+    await gu.setCustomWidgetUrl(`${serving.url}/zap`, {openGallery: false});
+    await gu.openWidgetPanel();
     await setAccess("full");
     await gu.waitForServer();
 
@@ -537,10 +510,10 @@ describe('CustomView', function() {
     const doc = await mainSession.tempDoc(cleanup, 'FetchSelectedOptions.grist', {load: false});
     await mainSession.loadDoc(`/doc/${doc.id}`);
 
-    await gu.toggleSidePanel('right', 'open');
     await gu.getSection('TABLE1 Custom').click();
-    await driver.find('.test-config-widget-url').click();
-    await gu.sendKeys(`${serving.url}/fetchSelectedOptions`, Key.ENTER);
+    await gu.setCustomWidgetUrl(`${serving.url}/fetchSelectedOptions`);
+    await gu.openWidgetPanel();
+    await setAccess("full");
     await gu.waitForServer();
 
     const expected = {
@@ -620,8 +593,10 @@ describe('CustomView', function() {
     }
 
     await inFrame(async () => {
-      const parsed = await getData(12);
-      assert.deepEqual(parsed, expected);
+      await gu.waitToPass(async () => {
+        const parsed = await getData(12);
+        assert.deepEqual(parsed, expected);
+      }, 1000);
     });
 
     // Change the access level away from 'full'.
diff --git a/test/nbrowser/CustomWidgets.ts b/test/nbrowser/CustomWidgets.ts
index 4730631d..bcd4aeaa 100644
--- a/test/nbrowser/CustomWidgets.ts
+++ b/test/nbrowser/CustomWidgets.ts
@@ -20,20 +20,46 @@ const widgetEndpoint = '/widget';
 const CUSTOM_URL = 'Custom URL';
 
 // Create some widgets:
-const widget1: ICustomWidget = {widgetId: '1', name: 'W1', url: widgetEndpoint + '?name=W1'};
-const widget2: ICustomWidget = {widgetId: '2', name: 'W2', url: widgetEndpoint + '?name=W2'};
+const widget1: ICustomWidget = {
+  widgetId: '1',
+  name: 'W1',
+  url: widgetEndpoint + '?name=W1',
+  description: 'Widget 1 description',
+  authors: [
+    {
+      name: 'Developer 1',
+    },
+    {
+      name: 'Developer 2',
+    },
+  ],
+  isGristLabsMaintained: true,
+  lastUpdatedAt: '2024-07-30T00:13:31-04:00',
+};
+const widget2: ICustomWidget = {
+  widgetId: '2',
+  name: 'W2',
+  url: widgetEndpoint + '?name=W2',
+};
 const widgetWithTheme: ICustomWidget = {
   widgetId: '3',
   name: 'WithTheme',
   url: widgetEndpoint + '?name=WithTheme',
+  isGristLabsMaintained: true,
 };
 const widgetNoPluginApi: ICustomWidget = {
   widgetId: '4',
   name: 'NoPluginApi',
   url: widgetEndpoint + '?name=NoPluginApi',
+  isGristLabsMaintained: true,
 };
-const fromAccess = (level: AccessLevel) =>
-  ({widgetId: level, name: level, url: widgetEndpoint, accessLevel: level}) as ICustomWidget;
+const fromAccess = (level: AccessLevel): ICustomWidget => ({
+  widgetId: level,
+  name: level,
+  url: widgetEndpoint,
+  accessLevel: level,
+  isGristLabsMaintained: true,
+});
 const widgetNone = fromAccess(AccessLevel.none);
 const widgetRead = fromAccess(AccessLevel.read_table);
 const widgetFull = fromAccess(AccessLevel.full);
@@ -51,23 +77,27 @@ describe('CustomWidgets', function () {
   gu.bigScreen();
   const cleanup = setupTestSuite();
 
+  let oldEnv: EnvironmentSnapshot;
+
   // Holds url for sample widget server.
   let widgetServerUrl = '';
 
   // Switches widget manifest url
   async function useManifest(url: string) {
     await server.testingHooks.setWidgetRepositoryUrl(url ? `${widgetServerUrl}${url}` : '');
+  }
+
+  async function reloadWidgets() {
     await driver.executeAsyncScript(
       (done: any) => (window as any).gristApp?.topAppModel.testReloadWidgets().then(done).catch(done) || done()
     );
   }
 
-
-
   before(async function () {
     if (server.isExternalServer()) {
       this.skip();
     }
+
     // Create simple widget server that serves manifest.json file, some widgets and some error pages.
     const widgetServer = await serveSomething(app => {
       app.get('/404', (_, res) => res.sendStatus(404).end()); // not found
@@ -105,32 +135,31 @@ describe('CustomWidgets', function () {
     cleanup.addAfterAll(widgetServer.shutdown);
     widgetServerUrl = widgetServer.url;
 
-    // Start with valid endpoint and 2 widgets.
+    oldEnv = new EnvironmentSnapshot();
+    process.env.GRIST_WIDGET_LIST_URL = `${widgetServerUrl}${manifestEndpoint}`;
+    await server.restart();
+
+    // Start with 2 widgets.
     widgets = [widget1, widget2];
-    await useManifest(manifestEndpoint);
 
     const session = await gu.session().login();
     await session.tempDoc(cleanup, 'Hello.grist');
-    // Add custom section.
-    await gu.addNewSection(/Custom/, /Table1/, {selectBy: /TABLE1/});
 
+    // Add custom section.
+    await gu.addNewSection(/Custom/, /Table1/, {customWidget: /Custom URL/, selectBy: /TABLE1/});
   });
 
   after(async function() {
     await server.testingHooks.setWidgetRepositoryUrl('');
+    oldEnv.restore();
+    await server.restart();
   });
 
-  // Open or close widget menu.
-  const toggle = async () => await driver.findWait('.test-config-widget-select .test-select-open', 1000).click();
-  // Get current value from widget menu.
-  const current = () => driver.find('.test-config-widget-select .test-select-open').getText();
-  // Get options from widget menu (must be first opened).
-  const options = () => driver.findAll('.test-select-menu li', e => e.getText());
-  // Select widget from the menu.
-  const select = async (text: string | RegExp) => {
-    await driver.findContent('.test-select-menu li', text).click();
-    await gu.waitForServer();
-  };
+  afterEach(() => gu.checkForErrors());
+
+  // Get available widgets from widget gallery (must be first opened).
+  const galleryWidgets = () => driver.findAll('.test-custom-widget-gallery-widget-name', e => e.getText());
+
   // Get rendered content from custom section.
   const content = async () => {
       return gu.doInIframe(await getCustomWidgetFrame(), async ()=>{
@@ -169,19 +198,6 @@ describe('CustomWidgets', function () {
       return result === "__undefined__" ? undefined : result;
     });
   }
-  // Replace url for the Custom URL widget.
-  const setUrl = async (url: string) => {
-    await driver.find('.test-config-widget-url').click();
-    // First clear textbox.
-    await gu.sendKeys(await gu.selectAllKey(), Key.DELETE);
-    if (url) {
-      await gu.sendKeys(`${widgetServerUrl}${url}`, Key.ENTER);
-    } else {
-      await gu.sendKeys(Key.ENTER);
-    }
-  };
-  // Get an URL from the URL textbox.
-  const getUrl = () => driver.find('.test-config-widget-url').value();
   // Get first error message from error toasts.
   const getErrorMessage = async () => (await gu.getToasts())[0];
   // Changes active section to recreate creator panel.
@@ -215,8 +231,6 @@ describe('CustomWidgets', function () {
   const reject = () => driver.find(".test-config-widget-access-reject").click();
 
   async function enableWidgetsAndShowPanel() {
-    // Override gristConfig to enable widget list.
-    await driver.executeScript('window.gristConfig.enableWidgetRepository = true;');
     // We need to be sure that widget configuration panel is open all the time.
     await gu.toggleSidePanel('right', 'open');
     await recreatePanel();
@@ -226,68 +240,65 @@ describe('CustomWidgets', function () {
   describe('RightWidgetMenu', () => {
     beforeEach(enableWidgetsAndShowPanel);
 
-    it('should show widgets in dropdown', async () => {
-      await gu.toggleSidePanel('right', 'open');
-      await driver.find('.test-right-tab-pagewidget').click();
-      await gu.waitForServer();
-      await driver.find('.test-config-widget').click();
-      await gu.waitForServer(); // Wait for widgets to load.
+    afterEach(() => gu.checkForErrors());
 
-      // Selectbox should have select label.
-      assert.equal(await current(), CUSTOM_URL);
-
-      // There should be 3 options (together with Custom URL)
-      await toggle();
-      assert.deepEqual(await options(), [CUSTOM_URL, widget1.name, widget2.name]);
-      await toggle();
+    it('should show button to open gallery', async () => {
+      const button = await driver.find('.test-config-widget-open-custom-widget-gallery');
+      assert.equal(await button.getText(), 'Custom URL');
+      await button.click();
+      assert.isTrue(await driver.find('.test-custom-widget-gallery-container').isDisplayed());
+      await gu.sendKeys(Key.ESCAPE, Key.ESCAPE);
+      assert.isFalse(await driver.find('.test-custom-widget-gallery-container').isPresent());
     });
 
     it('should switch between widgets', async () => {
-      // Test custom URL.
-      await toggle();
-      await select(CUSTOM_URL);
-      assert.equal(await current(), CUSTOM_URL);
-      assert.equal(await getUrl(), '');
-      await setUrl('/200');
+      // Test Custom URL.
+      assert.equal(await gu.getCustomWidgetName(), CUSTOM_URL);
+      assert.isTrue((await content()).startsWith('Custom widget'));
+      await gu.setCustomWidgetUrl(`${widgetServerUrl}/200`);
+      assert.equal(await gu.getCustomWidgetName(), CUSTOM_URL);
       assert.equal(await content(), 'OK');
 
       // Test first widget.
-      await toggle();
-      await select(widget1.name);
-      assert.equal(await current(), widget1.name);
+      await gu.setCustomWidget(widget1.name);
+      assert.equal(await gu.getCustomWidgetName(), widget1.name);
+      assert.equal(await gu.getCustomWidgetInfo('description'), widget1.description);
+      assert.equal(await gu.getCustomWidgetInfo('developer'), widget1.authors?.[0].name);
+      assert.equal(await gu.getCustomWidgetInfo('last-updated'), 'July 30, 2024');
       assert.equal(await content(), widget1.name);
 
       // Test second widget.
-      await toggle();
-      await select(widget2.name);
-      assert.equal(await current(), widget2.name);
+      await gu.setCustomWidget(widget2.name);
+      assert.equal(await gu.getCustomWidgetName(), widget2.name);
+      assert.equal(await gu.getCustomWidgetInfo('description'), '');
+      assert.equal(await gu.getCustomWidgetInfo('developer'), '');
+      assert.equal(await gu.getCustomWidgetInfo('last-updated'), '');
       assert.equal(await content(), widget2.name);
 
       // Go back to Custom URL.
-      await toggle();
-      await select(CUSTOM_URL);
-      assert.equal(await getUrl(), '');
-      assert.equal(await current(), CUSTOM_URL);
-      await setUrl('/200');
+      await gu.setCustomWidget(CUSTOM_URL);
+      assert.equal(await gu.getCustomWidgetName(), CUSTOM_URL);
+      assert.isTrue((await content()).startsWith('Custom widget'));
+      await gu.setCustomWidgetUrl(`${widgetServerUrl}/200`);
+      assert.equal(await gu.getCustomWidgetName(), CUSTOM_URL);
       assert.equal(await content(), 'OK');
 
       // Clear url and test if message page is shown.
-      await setUrl('');
-      assert.equal(await current(), CUSTOM_URL);
-      assert.isTrue((await content()).startsWith('Custom widget')); // start page
+      await gu.setCustomWidgetUrl('');
+      assert.equal(await gu.getCustomWidgetName(), CUSTOM_URL);
+      assert.isTrue((await content()).startsWith('Custom widget'));
 
       await recreatePanel();
-      assert.equal(await current(), CUSTOM_URL);
-      await gu.undo(7);
+      assert.equal(await gu.getCustomWidgetName(), CUSTOM_URL);
+      await gu.undo(6);
     });
 
     it('should support theme variables', async () => {
       widgets = [widgetWithTheme];
-      await useManifest(manifestEndpoint);
+      await reloadWidgets();
       await recreatePanel();
-      await toggle();
-      await select(widgetWithTheme.name);
-      assert.equal(await current(), widgetWithTheme.name);
+      await gu.setCustomWidget(widgetWithTheme.name);
+      assert.equal(await gu.getCustomWidgetName(), widgetWithTheme.name);
       assert.equal(await content(), widgetWithTheme.name);
 
       const getWidgetColor = async () => {
@@ -316,18 +327,14 @@ describe('CustomWidgets', function () {
 
       // Check that the widget is back to using the GristLight text color.
       assert.equal(await getWidgetColor(), 'rgba(38, 38, 51, 1)');
-
-      // Re-enable widget repository.
-      await driver.executeScript('window.gristConfig.enableWidgetRepository = true;');
     });
 
     it("should support widgets that don't use the plugin api", async () => {
       widgets = [widgetNoPluginApi];
-      await useManifest(manifestEndpoint);
+      await reloadWidgets();
       await recreatePanel();
-      await toggle();
-      await select(widgetNoPluginApi.name);
-      assert.equal(await current(), widgetNoPluginApi.name);
+      await gu.setCustomWidget(widgetNoPluginApi.name);
+      assert.equal(await gu.getCustomWidgetName(), widgetNoPluginApi.name);
 
       // Check that the widget loaded and its iframe is visible.
       assert.equal(await content(), widgetNoPluginApi.name);
@@ -335,7 +342,7 @@ describe('CustomWidgets', function () {
 
       // Revert to original configuration.
       widgets = [widget1, widget2];
-      await useManifest(manifestEndpoint);
+      await reloadWidgets();
       await recreatePanel();
     });
 
@@ -343,13 +350,15 @@ describe('CustomWidgets', function () {
       const testError = async (url: string, error: string) => {
         // Switch section to rebuild the creator panel.
         await useManifest(url);
+        await reloadWidgets();
         await recreatePanel();
         assert.include(await getErrorMessage(), error);
         await gu.wipeToasts();
-        // List should contain only a Custom URL.
-        await toggle();
-        assert.deepEqual(await options(), [CUSTOM_URL]);
-        await toggle();
+        // Gallery should only contain the Custom URL widget.
+        await gu.openCustomWidgetGallery();
+        assert.deepEqual(await galleryWidgets(), [CUSTOM_URL]);
+        await gu.wipeToasts();
+        await gu.sendKeys(Key.ESCAPE);
       };
 
       await testError('/404', "Remote widget list not found");
@@ -361,6 +370,7 @@ describe('CustomWidgets', function () {
 
       // Reset to valid manifest.
       await useManifest(manifestEndpoint);
+      await reloadWidgets();
       await recreatePanel();
     });
 
@@ -371,15 +381,14 @@ describe('CustomWidgets', function () {
      */
     it.skip('should show widget when it was removed from list', async () => {
       // Select widget1 and then remove it from the list.
-      await toggle();
-      await select(widget1.name);
+      await gu.setCustomWidget(widget1.name);
       widgets = [widget2];
       // Invalidate cache.
-      await useManifest(manifestEndpoint);
+      await reloadWidgets();
       // Toggle sections to reset creator panel and fetch list of available widgets.
       await recreatePanel();
       // But still should be selected with a correct url.
-      assert.equal(await current(), widget1.name);
+      assert.equal(await gu.getCustomWidgetName(), widget1.name);
       assert.equal(await content(), widget1.name);
       await gu.undo(1);
     });
@@ -387,26 +396,22 @@ describe('CustomWidgets', function () {
     it('should switch access level to none on new widget', async () => {
       widgets = [widget1, widget2];
       await recreatePanel();
-      await toggle();
-      await select(widget1.name);
+      await gu.setCustomWidget(widget1.name);
       assert.equal(await access(), AccessLevel.none);
       await access(AccessLevel.full);
       assert.equal(await access(), AccessLevel.full);
 
-      await toggle();
-      await select(widget2.name);
+      await gu.setCustomWidget(widget2.name);
       assert.equal(await access(), AccessLevel.none);
       await access(AccessLevel.full);
       assert.equal(await access(), AccessLevel.full);
 
-      await toggle();
-      await select(CUSTOM_URL);
+      await gu.setCustomWidget(CUSTOM_URL);
       assert.equal(await access(), AccessLevel.none);
       await access(AccessLevel.full);
       assert.equal(await access(), AccessLevel.full);
 
-      await toggle();
-      await select(widget2.name);
+      await gu.setCustomWidget(widget2.name);
       assert.equal(await access(), AccessLevel.none);
       await access(AccessLevel.full);
       assert.equal(await access(), AccessLevel.full);
@@ -416,19 +421,18 @@ describe('CustomWidgets', function () {
 
     it('should prompt for access change', async () => {
       widgets = [widget1, widget2, widgetFull, widgetNone, widgetRead];
-      await useManifest(manifestEndpoint);
+      await reloadWidgets();
       await recreatePanel();
 
       const test = async (w: ICustomWidget) => {
         // Select widget without desired access level
-        await toggle();
-        await select(widget1.name);
+        await gu.setCustomWidget(widget1.name);
         assert.isFalse(await hasPrompt());
         assert.equal(await access(), AccessLevel.none);
 
         // Select one with desired access level
-        await toggle();
-        await select(w.name);
+        await gu.setCustomWidget(w.name);
+
         // Access level should be still none (test by content which will display access level from query string)
         assert.equal(await content(), AccessLevel.none);
         assert.equal(await access(), AccessLevel.none);
@@ -440,13 +444,11 @@ describe('CustomWidgets', function () {
         assert.equal(await access(), w.accessLevel);
 
         // Do the same, but this time reject
-        await toggle();
-        await select(widget1.name);
+        await gu.setCustomWidget(widget1.name);
         assert.isFalse(await hasPrompt());
         assert.equal(await access(), AccessLevel.none);
 
-        await toggle();
-        await select(w.name);
+        await gu.setCustomWidget(w.name);
         assert.isTrue(await hasPrompt());
         assert.equal(await content(), AccessLevel.none);
 
@@ -462,14 +464,12 @@ describe('CustomWidgets', function () {
 
     it('should auto accept none access level', async () => {
       // Select widget without access level
-      await toggle();
-      await select(widget1.name);
+      await gu.setCustomWidget(widget1.name);
       assert.isFalse(await hasPrompt());
       assert.equal(await access(), AccessLevel.none);
 
       // Switch to one with none access level
-      await toggle();
-      await select(widgetNone.name);
+      await gu.setCustomWidget(widgetNone.name);
       assert.isFalse(await hasPrompt());
       assert.equal(await access(), AccessLevel.none);
       assert.equal(await content(), AccessLevel.none);
@@ -477,14 +477,12 @@ describe('CustomWidgets', function () {
 
     it('should show prompt when user switches sections', async () => {
       // Select widget without access level
-      await toggle();
-      await select(widget1.name);
+      await gu.setCustomWidget(widget1.name);
       assert.isFalse(await hasPrompt());
       assert.equal(await access(), AccessLevel.none);
 
       // Switch to one with full access level
-      await toggle();
-      await select(widgetFull.name);
+      await gu.setCustomWidget(widgetFull.name);
       assert.isTrue(await hasPrompt());
 
       // Switch section, and test if prompt is hidden
@@ -496,19 +494,16 @@ describe('CustomWidgets', function () {
 
     it('should hide prompt when user switches widget', async () => {
       // Select widget without access level
-      await toggle();
-      await select(widget1.name);
+      await gu.setCustomWidget(widget1.name);
       assert.isFalse(await hasPrompt());
       assert.equal(await access(), AccessLevel.none);
 
       // Switch to one with full access level
-      await toggle();
-      await select(widgetFull.name);
+      await gu.setCustomWidget(widgetFull.name);
       assert.isTrue(await hasPrompt());
 
       // Switch to another level.
-      await toggle();
-      await select(widget1.name);
+      await gu.setCustomWidget(widget1.name);
       assert.isFalse(await hasPrompt());
       assert.equal(await access(), AccessLevel.none);
     });
@@ -516,8 +511,7 @@ describe('CustomWidgets', function () {
     it('should hide prompt when manually changes access level', async () => {
       // Select widget with no access level
       const selectNone = async () => {
-        await toggle();
-        await select(widgetNone.name);
+        await gu.setCustomWidget(widgetNone.name);
         assert.isFalse(await hasPrompt());
         assert.equal(await access(), AccessLevel.none);
         assert.equal(await content(), AccessLevel.none);
@@ -525,8 +519,7 @@ describe('CustomWidgets', function () {
 
       // Selects widget with full access level
       const selectFull = async () => {
-        await toggle();
-        await select(widgetFull.name);
+        await gu.setCustomWidget(widgetFull.name);
         assert.isTrue(await hasPrompt());
         assert.equal(await content(), AccessLevel.none);
         assert.equal(await content(), AccessLevel.none);
@@ -559,26 +552,140 @@ describe('CustomWidgets', function () {
       assert.equal(await access(), AccessLevel.none);
       assert.equal(await content(), AccessLevel.none);
     });
+  });
 
-    it('should offer only custom url when disabled', async () => {
-      await toggle();
-      await select(CUSTOM_URL);
+  describe('gallery', () => {
+    afterEach(() => gu.checkForErrors());
+
+    it('should show available widgets', async () => {
+      await gu.openCustomWidgetGallery();
+      assert.deepEqual(
+        await driver.findAll('.test-custom-widget-gallery-widget-name', (el) => el.getText()),
+        ['Custom URL', 'full', 'none', 'read table', 'W1', 'W2']
+      );
+    });
+
+    it('should show available metadata', async () => {
+      assert.deepEqual(
+        await driver.findAll('.test-custom-widget-gallery-widget', (el) =>
+          el.matches('.test-custom-widget-gallery-widget-custom')),
+        [true, false, false, false, false, false]
+      );
+      assert.deepEqual(
+        await driver.findAll('.test-custom-widget-gallery-widget', (el) =>
+          el.matches('.test-custom-widget-gallery-widget-grist')),
+        [false, true, true, true, true, false]
+      );
+      assert.deepEqual(
+        await driver.findAll('.test-custom-widget-gallery-widget', (el) =>
+          el.matches('.test-custom-widget-gallery-widget-community')),
+        [false, false, false, false, false, true]
+      );
+      assert.deepEqual(
+        await driver.findAll('.test-custom-widget-gallery-widget-description', (el) => el.getText()),
+        [
+          'Add a widget from outside this gallery.',
+          '(Missing info)',
+          '(Missing info)',
+          '(Missing info)',
+          'Widget 1 description',
+          '(Missing info)',
+        ]
+      );
+      assert.deepEqual(
+        await driver.findAll('.test-custom-widget-gallery-widget-developer', (el) => el.getText()),
+        [
+          '(Missing info)',
+          '(Missing info)',
+        ]
+      );
+      assert.deepEqual(
+        await driver.findAll('.test-custom-widget-gallery-widget-last-updated', (el) => el.getText()),
+        [
+          '(Missing info)',
+          '(Missing info)',
+          '(Missing info)',
+          'July 30, 2024',
+          '(Missing info)',
+        ]
+      );
+    });
+
+    it('should filter widgets on search', async () => {
+      await driver.find('.test-custom-widget-gallery-search').click();
+      await gu.sendKeys('Custom');
+      await gu.waitToPass(async () => {
+        assert.deepEqual(
+          await driver.findAll('.test-custom-widget-gallery-widget-name', (el) => el.getText()),
+          ['Custom URL']
+        );
+      }, 200);
+      await gu.sendKeys(await gu.selectAllKey(), Key.DELETE);
+      await gu.waitToPass(async () => {
+        assert.deepEqual(
+          await driver.findAll('.test-custom-widget-gallery-widget-name', (el) => el.getText()),
+          ['Custom URL', 'full', 'none', 'read table', 'W1', 'W2']
+        );
+      }, 200);
+      await gu.sendKeys('W');
+      await gu.waitToPass(async () => {
+        assert.deepEqual(
+          await driver.findAll('.test-custom-widget-gallery-widget-name', (el) => el.getText()),
+          ['Custom URL', 'W1', 'W2']
+        );
+      }, 200);
+      await gu.sendKeys(await gu.selectAllKey(), Key.DELETE, 'tab');
+      await gu.waitToPass(async () => {
+        assert.deepEqual(
+          await driver.findAll('.test-custom-widget-gallery-widget-name', (el) => el.getText()),
+          ['read table']
+        );
+      }, 200);
+      await gu.sendKeys(await gu.selectAllKey(), Key.DELETE, 'Markdown');
+      await gu.waitToPass(async () => {
+        assert.deepEqual(
+          await driver.findAll('.test-custom-widget-gallery-widget-name', (el) => el.getText()),
+          []
+        );
+      }, 200);
+      await gu.sendKeys(await gu.selectAllKey(), Key.DELETE, 'Developer 1');
+      await gu.waitToPass(async () => {
+        assert.deepEqual(
+          await driver.findAll('.test-custom-widget-gallery-widget-name', (el) => el.getText()),
+          ['W1']
+        );
+      }, 200);
+    });
+
+    it('should only show Custom URL widget when repository is disabled', async () => {
+      await gu.sendKeys(Key.ESCAPE);
       await driver.executeScript('window.gristConfig.enableWidgetRepository = false;');
-      await recreatePanel();
-      assert.isTrue(await driver.find('.test-config-widget-url').isDisplayed());
-      assert.isFalse(await driver.find('.test-config-widget-select').isPresent());
+      await driver.executeAsyncScript(
+        (done: any) => (window as any).gristApp?.topAppModel.testReloadWidgets().then(done).catch(done) || done()
+      );
+      await gu.openCustomWidgetGallery();
+      assert.deepEqual(
+        await driver.findAll('.test-custom-widget-gallery-widget-name', (el) => el.getText()),
+        ['Custom URL']
+      );
+      await gu.sendKeys(Key.ESCAPE);
+      await driver.executeScript('window.gristConfig.enableWidgetRepository = true;');
+      await driver.executeAsyncScript(
+        (done: any) => (window as any).gristApp?.topAppModel.testReloadWidgets().then(done).catch(done) || done()
+      );
     });
   });
 
   describe('gristApiSupport', async ()=>{
     beforeEach(async function () {
-      // Override gristConfig to enable widget list.
-      await driver.executeScript('window.gristConfig.enableWidgetRepository = true;');
       // We need to be sure that widget configuration panel is open all the time.
       await gu.toggleSidePanel('right', 'open');
       await recreatePanel();
       await driver.findWait('.test-right-tab-pagewidget', 100).click();
     });
+
+    afterEach(() => gu.checkForErrors());
+
     it('should set language in widget url', async () => {
       function languageMenu() {
         return gu.currentDriver().find('.test-account-page-language .test-select-open');
@@ -602,10 +709,9 @@ describe('CustomWidgets', function () {
       }
 
       widgets = [widget1];
-      await useManifest(manifestEndpoint);
+      await reloadWidgets();
       await gu.openWidgetPanel();
-      await toggle();
-      await select(widget1.name);
+      await gu.setCustomWidget(widget1.name);
       //Switch language to Polish
       await switchLanguage('Polski');
       //Check if widgets have "pl" in url
@@ -621,8 +727,6 @@ describe('CustomWidgets', function () {
       await gu.toggleSidePanel('right', 'open');
       await driver.find('.test-config-widget').click();
       await gu.waitForServer();
-      await toggle();
-      await select(widget1.name);
       await access(AccessLevel.full);
 
       // Check an upsert works.
@@ -735,6 +839,7 @@ describe('CustomWidgets', function () {
     });
 
     afterEach(async function() {
+      await gu.checkForErrors();
       oldEnv.restore();
       await server.restart();
       await gu.reloadDoc();
@@ -745,10 +850,10 @@ describe('CustomWidgets', function () {
         // Double-check that using one external widget, we see
         // just that widget listed.
         widgets = [widget1];
-        await useManifest(manifestEndpoint);
+        await reloadWidgets();
         await enableWidgetsAndShowPanel();
-        await toggle();
-        assert.deepEqual(await options(), [
+        await gu.openCustomWidgetGallery();
+        assert.deepEqual(await galleryWidgets(), [
           CUSTOM_URL, widget1.name,
         ]);
 
@@ -848,13 +953,13 @@ describe('CustomWidgets', function () {
         await gu.reloadDoc();
 
         // Continue using one external widget.
-        await useManifest(manifestEndpoint);
+        await reloadWidgets();
         await enableWidgetsAndShowPanel();
 
         // Check we see one external widget and two bundled ones.
-        await toggle();
-        assert.deepEqual(await options(), [
-          CUSTOM_URL, widget1.name, 'P1 (My Widgets)', 'P2 (My Widgets)',
+        await gu.openCustomWidgetGallery();
+        assert.deepEqual(await galleryWidgets(), [
+          CUSTOM_URL, 'P1 (My Widgets)', 'P2 (My Widgets)', widget1.name,
         ]);
 
         // Prepare to check content of widgets.
@@ -867,24 +972,22 @@ describe('CustomWidgets', function () {
         }
 
         // Check built-in P1 works as expected.
-        await select(/P1/);
-        assert.equal(await current(), 'P1 (My Widgets)');
+        await gu.setCustomWidget(/P1/, {openGallery: false});
+        assert.equal(await gu.getCustomWidgetName(), 'P1 (My Widgets)');
         await gu.waitToPass(async () => {
           assert.equal(await getWidgetText(), 'P1');
         });
 
         // Check external W1 works as expected.
-        await toggle();
-        await select(/W1/);
-        assert.equal(await current(), 'W1');
+        await gu.setCustomWidget(/W1/);
+        assert.equal(await gu.getCustomWidgetName(), 'W1');
         await gu.waitToPass(async () => {
           assert.equal(await getWidgetText(), 'W1');
         });
 
         // Check build-in P2 works as expected.
-        await toggle();
-        await select(/P2/);
-        assert.equal(await current(), 'P2 (My Widgets)');
+        await gu.setCustomWidget(/P2/);
+        assert.equal(await gu.getCustomWidgetName(), 'P2 (My Widgets)');
         await gu.waitToPass(async () => {
           assert.equal(await getWidgetText(), 'P2');
         });
diff --git a/test/nbrowser/CustomWidgetsConfig.ts b/test/nbrowser/CustomWidgetsConfig.ts
index 7222fc97..f9f5e6d0 100644
--- a/test/nbrowser/CustomWidgetsConfig.ts
+++ b/test/nbrowser/CustomWidgetsConfig.ts
@@ -1,8 +1,9 @@
+import {AccessLevel} from 'app/common/CustomWidget';
 import {addToRepl, assert, driver, Key} from 'mocha-webdriver';
 import * as gu from 'test/nbrowser/gristUtils';
 import {server, setupTestSuite} from 'test/nbrowser/testUtils';
 import {addStatic, serveSomething} from 'test/server/customUtil';
-import {AccessLevel} from 'app/common/CustomWidget';
+import {EnvironmentSnapshot} from 'test/server/testUtils';
 
 // Valid manifest url.
 const manifestEndpoint = '/manifest.json';
@@ -16,7 +17,7 @@ const READ_WIDGET = 'Read';
 const FULL_WIDGET = 'Full';
 const COLUMN_WIDGET = 'COLUMN_WIDGET';
 const REQUIRED_WIDGET = 'REQUIRED_WIDGET';
-// Custom URL label in selectbox.
+// Custom URL label.
 const CUSTOM_URL = 'Custom URL';
 // Holds url for sample widget server.
 let widgetServerUrl = '';
@@ -27,14 +28,9 @@ function createConfigUrl(ready?: any) {
   return ready ? `${widgetServerUrl}/config?ready=` + encodeURI(JSON.stringify(ready)) : `${widgetServerUrl}/config`;
 }
 
-// Open or close widget menu.
 const click = (selector: string) => driver.find(`${selector}`).click();
 const toggleDrop = (selector: string) => click(`${selector} .test-select-open`);
-const toggleWidgetMenu = () => toggleDrop('.test-config-widget-select');
 const getOptions = () => driver.findAll('.test-select-menu li', el => el.getText());
-// Get current value from widget menu.
-const currentWidget = () => driver.find('.test-config-widget-select .test-select-open').getText();
-// Select widget from the menu.
 const clickOption = async (text: string | RegExp) => {
   await driver.findContent('.test-select-menu li', text).click();
   await gu.waitForServer();
@@ -58,13 +54,11 @@ async function getListItems(col: string) {
     .findAll(`.test-config-widget-map-list-for-${col} .test-config-widget-ref-select-label`, el => el.getText());
 }
 
-// When refreshing, we need to make sure widget repository is enabled once again.
 async function refresh() {
   await driver.navigate().refresh();
   await gu.waitForDocToLoad();
   // Switch section and enable config
   await gu.selectSectionByTitle('Table');
-  await driver.executeScript('window.gristConfig.enableWidgetRepository = true;');
   await gu.selectSectionByTitle('Widget');
 }
 
@@ -130,6 +124,7 @@ describe('CustomWidgetsConfig', function () {
   let mainSession: gu.Session;
   gu.bigScreen();
 
+  let oldEnv: EnvironmentSnapshot;
 
   addToRepl('getOptions', getOptions);
 
@@ -137,6 +132,12 @@ describe('CustomWidgetsConfig', function () {
     if (server.isExternalServer()) {
       this.skip();
     }
+
+    oldEnv = new EnvironmentSnapshot();
+    // Set to an unused URL so that the client reports that widgets are available.
+    process.env.GRIST_WIDGET_LIST_URL = 'unused';
+    await server.restart();
+
     // Create simple widget server that serves manifest.json file, some widgets and some error pages.
     const widgetServer = await serveSomething(app => {
       app.get('/manifest.json', (_, res) => {
@@ -188,25 +189,23 @@ describe('CustomWidgetsConfig', function () {
     mainSession = await gu.session().login();
     const doc = await mainSession.tempDoc(cleanup, 'CustomWidget.grist');
     docId = doc.id;
-    // Make sure widgets are enabled.
-    await driver.executeScript('window.gristConfig.enableWidgetRepository = true;');
     await gu.toggleSidePanel('right', 'open');
     await gu.selectSectionByTitle('Widget');
   });
 
   after(async function() {
     await server.testingHooks.setWidgetRepositoryUrl('');
+    oldEnv.restore();
+    await server.restart();
   });
 
   beforeEach(async () => {
     // Before each test, we will switch to Custom Url (to cleanup the widget)
     // and then back to the Tester widget.
-    if ((await currentWidget()) !== CUSTOM_URL) {
-      await toggleWidgetMenu();
-      await clickOption(CUSTOM_URL);
+    if ((await gu.getCustomWidgetName()) !== CUSTOM_URL) {
+      await gu.setCustomWidget(CUSTOM_URL);
     }
-    await toggleWidgetMenu();
-    await clickOption(TESTER_WIDGET);
+    await gu.setCustomWidget(TESTER_WIDGET);
     await widget.waitForFrame();
   });
 
@@ -218,8 +217,7 @@ describe('CustomWidgetsConfig', function () {
     assert.isFalse(await driver.find('.test-custom-widget-ready').isPresent());
 
     // Now select the widget that requires a column.
-    await toggleWidgetMenu();
-    await clickOption(REQUIRED_WIDGET);
+    await gu.setCustomWidget(REQUIRED_WIDGET);
     await gu.acceptAccessRequest();
 
     // The widget iframe should be covered with a text explaining that the widget is not configured.
@@ -251,15 +249,11 @@ describe('CustomWidgetsConfig', function () {
   });
 
   it('should hide mappings when there is no good column', async () => {
-    if ((await currentWidget()) !== CUSTOM_URL) {
-      await toggleWidgetMenu();
-      await clickOption(CUSTOM_URL);
-    }
-    await gu.setWidgetUrl(
+    await gu.setCustomWidgetUrl(
       createConfigUrl({
         columns: [{name: 'M2', type: 'Date', optional: true}],
         requiredAccess: 'read table',
-      })
+      }),
     );
 
     await widget.waitForFrame();
@@ -307,11 +301,7 @@ describe('CustomWidgetsConfig', function () {
 
   it('should clear optional mapping', async () => {
     const revert = await gu.begin();
-    if ((await currentWidget()) !== CUSTOM_URL) {
-      await toggleWidgetMenu();
-      await clickOption(CUSTOM_URL);
-    }
-    await gu.setWidgetUrl(
+    await gu.setCustomWidgetUrl(
       createConfigUrl({
         columns: [{name: 'M2', type: 'Date', optional: true}],
         requiredAccess: 'read table',
@@ -356,9 +346,7 @@ describe('CustomWidgetsConfig', function () {
   it('should render columns mapping', async () => {
     const revert = await gu.begin();
     assert.isTrue(await driver.find('.test-vfc-visible-fields-select-all').isPresent());
-    await toggleWidgetMenu();
-    // Select widget that has single column configuration.
-    await clickOption(COLUMN_WIDGET);
+    await gu.setCustomWidget(COLUMN_WIDGET);
     await widget.waitForFrame();
     await gu.acceptAccessRequest();
     await widget.waitForPendingRequests();
@@ -386,11 +374,9 @@ describe('CustomWidgetsConfig', function () {
 
   it('should render multiple mappings', async () => {
     const revert = await gu.begin();
-    await toggleWidgetMenu();
-    await clickOption(CUSTOM_URL);
     // This is not standard way of creating widgets. The widgets in this test is reading this parameter
     // and is using it to invoke the ready method.
-    await gu.setWidgetUrl(
+    await gu.setCustomWidgetUrl(
       createConfigUrl({
         columns: ['M1', {name: 'M2', optional: true}, {name: 'M3', title: 'T3'}, {name: 'M4', type: 'Text'}],
         requiredAccess: 'read table',
@@ -448,8 +434,7 @@ describe('CustomWidgetsConfig', function () {
   it('should clear mappings on widget switch', async () => {
     const revert = await gu.begin();
 
-    await toggleWidgetMenu();
-    await clickOption(COLUMN_WIDGET);
+    await gu.setCustomWidget(COLUMN_WIDGET);
     await widget.waitForFrame();
     await gu.acceptAccessRequest();
     await widget.waitForPendingRequests();
@@ -466,8 +451,7 @@ describe('CustomWidgetsConfig', function () {
     await clickOption('A');
 
     // Now change to a widget without columns
-    await toggleWidgetMenu();
-    await clickOption(NORMAL_WIDGET);
+    await gu.setCustomWidget(NORMAL_WIDGET);
 
     // Picker should disappear and column mappings should be visible
     assert.isTrue(await driver.find('.test-vfc-visible-fields-select-all').isPresent());
@@ -481,8 +465,7 @@ describe('CustomWidgetsConfig', function () {
       {id: 3, A: 'C'},
     ]);
     // Now go back to the widget with mappings.
-    await toggleWidgetMenu();
-    await clickOption(COLUMN_WIDGET);
+    await gu.setCustomWidget(COLUMN_WIDGET);
     await widget.waitForFrame();
     await gu.acceptAccessRequest();
     await widget.waitForPendingRequests();
@@ -494,9 +477,7 @@ describe('CustomWidgetsConfig', function () {
 
   it('should render multiple options', async () => {
     const revert = await gu.begin();
-    await toggleWidgetMenu();
-    await clickOption(CUSTOM_URL);
-    await gu.setWidgetUrl(
+    await gu.setCustomWidgetUrl(
       createConfigUrl({
         columns: [
           {name: 'M1', allowMultiple: true, optional: true},
@@ -578,9 +559,7 @@ describe('CustomWidgetsConfig', function () {
 
   it('should support multiple types in mappings', async () => {
     const revert = await gu.begin();
-    await toggleWidgetMenu();
-    await clickOption(CUSTOM_URL);
-    await gu.setWidgetUrl(
+    await gu.setCustomWidgetUrl(
       createConfigUrl({
         columns: [
           {name: 'M1', type: 'Date,DateTime', optional: true},
@@ -639,9 +618,7 @@ describe('CustomWidgetsConfig', function () {
 
   it('should support strictType setting', async () => {
     const revert = await gu.begin();
-    await toggleWidgetMenu();
-    await clickOption(CUSTOM_URL);
-    await gu.setWidgetUrl(
+    await gu.setCustomWidgetUrl(
       createConfigUrl({
         columns: [
           {name: 'Any', type: 'Any', strictType: true, optional: true},
@@ -683,9 +660,7 @@ describe('CustomWidgetsConfig', function () {
 
   it('should react to widget options change', async () => {
     const revert = await gu.begin();
-    await toggleWidgetMenu();
-    await clickOption(CUSTOM_URL);
-    await gu.setWidgetUrl(
+    await gu.setCustomWidgetUrl(
       createConfigUrl({
         columns: [
           {name: 'Choice', type: 'Choice', strictType: true, optional: true},
@@ -731,10 +706,8 @@ describe('CustomWidgetsConfig', function () {
 
   it('should remove mapping when column is deleted', async () => {
     const revert = await gu.begin();
-    await toggleWidgetMenu();
     // Prepare mappings for single and multiple columns
-    await clickOption(CUSTOM_URL);
-    await gu.setWidgetUrl(
+    await gu.setCustomWidgetUrl(
       createConfigUrl({
         columns: [{name: 'M1', optional: true}, {name: 'M2', allowMultiple: true, optional: true}],
         requiredAccess: 'read table',
@@ -820,10 +793,8 @@ describe('CustomWidgetsConfig', function () {
 
   it('should remove mapping when column type is changed', async () => {
     const revert = await gu.begin();
-    await toggleWidgetMenu();
     // Prepare mappings for single and multiple columns
-    await clickOption(CUSTOM_URL);
-    await gu.setWidgetUrl(
+    await gu.setCustomWidgetUrl(
       createConfigUrl({
         columns: [
           {name: 'M1', type: 'Text', optional: true},
@@ -900,10 +871,9 @@ describe('CustomWidgetsConfig', function () {
     await gu.undo();
 
     // Add Custom - no section option by default
-    await gu.addNewSection(/Custom/, /Table1/);
+    await gu.addNewSection(/Custom/, /Table1/, {customWidget: /Custom URL/});
     assert.isFalse(await hasSectionOption());
-    await toggleWidgetMenu();
-    await clickOption(TESTER_WIDGET);
+    await gu.setCustomWidget(TESTER_WIDGET);
     assert.isTrue(await hasSectionOption());
     await gu.undo(2);
   });
@@ -1058,30 +1028,19 @@ describe('CustomWidgetsConfig', function () {
 
   it('should show options action button', async () => {
     // Select widget without options
-    await toggleWidgetMenu();
-    await clickOption(NORMAL_WIDGET);
+    await gu.setCustomWidget(NORMAL_WIDGET);
     assert.isFalse(await hasSectionOption());
     // Select widget with options
-    await toggleWidgetMenu();
-    await clickOption(TESTER_WIDGET);
+    await gu.setCustomWidget(TESTER_WIDGET);
     assert.isTrue(await hasSectionOption());
     // Select widget without options
-    await toggleWidgetMenu();
-    await clickOption(NORMAL_WIDGET);
+    await gu.setCustomWidget(NORMAL_WIDGET);
     assert.isFalse(await hasSectionOption());
   });
 
   it('should prompt user for correct access level', async () => {
-    // Select widget without request
-    await toggleWidgetMenu();
-    await clickOption(NORMAL_WIDGET);
-    await widget.waitForFrame();
-    assert.isFalse(await gu.hasAccessPrompt());
-    assert.equal(await gu.widgetAccess(), AccessLevel.none);
-    assert.equal(await widget.access(), AccessLevel.none);
     // Select widget that requests read access.
-    await toggleWidgetMenu();
-    await clickOption(READ_WIDGET);
+    await gu.setCustomWidget(READ_WIDGET);
     await widget.waitForFrame();
     assert.isTrue(await gu.hasAccessPrompt());
     assert.equal(await gu.widgetAccess(), AccessLevel.none);
@@ -1091,8 +1050,7 @@ describe('CustomWidgetsConfig', function () {
     assert.equal(await gu.widgetAccess(), AccessLevel.read_table);
     assert.equal(await widget.access(), AccessLevel.read_table);
     // Select widget that requests full access.
-    await toggleWidgetMenu();
-    await clickOption(FULL_WIDGET);
+    await gu.setCustomWidget(FULL_WIDGET);
     await widget.waitForFrame();
     assert.isTrue(await gu.hasAccessPrompt());
     assert.equal(await gu.widgetAccess(), AccessLevel.none);
@@ -1101,7 +1059,7 @@ describe('CustomWidgetsConfig', function () {
     await widget.waitForPendingRequests();
     assert.equal(await gu.widgetAccess(), AccessLevel.full);
     assert.equal(await widget.access(), AccessLevel.full);
-    await gu.undo(5);
+    await gu.undo(4);
   });
 
   it('should pass readonly mode to custom widget', async () => {
@@ -1265,7 +1223,6 @@ const widget = {
    * any existing widget state (even if the Custom URL was already selected).
    */
   async resetWidget() {
-    await toggleWidgetMenu();
-    await clickOption(CUSTOM_URL);
+    await gu.setCustomWidget(CUSTOM_URL);
   }
 };
diff --git a/test/nbrowser/LinkingBidirectional.ts b/test/nbrowser/LinkingBidirectional.ts
index 7064f7da..8f1baaa6 100644
--- a/test/nbrowser/LinkingBidirectional.ts
+++ b/test/nbrowser/LinkingBidirectional.ts
@@ -219,16 +219,10 @@ describe('LinkingBidirectional', function() {
   });
 
   it('should support custom filters', async function() {
-    // Add a new custom section with a widget.
+    // Add a new page with a table and custom widget.
     await gu.addNewPage('Table', 'Classes', {});
-
-    // Rename this section as Data.
     await gu.renameActiveSection('Data');
-
-    // Add new custom section with a widget.
-    await gu.addNewSection('Custom', 'Classes', { selectBy: 'Data' });
-
-    // Rename this section as Custom.
+    await gu.addNewSection('Custom', 'Classes', {customWidget: /Custom URL/, selectBy: 'Data'});
     await gu.renameActiveSection('Custom');
 
     // Make sure it can be used as a filter.
diff --git a/test/nbrowser/RightPanel.ts b/test/nbrowser/RightPanel.ts
index a2f4af4b..a7877b1a 100644
--- a/test/nbrowser/RightPanel.ts
+++ b/test/nbrowser/RightPanel.ts
@@ -33,22 +33,17 @@ describe('RightPanel', function() {
     await gu.undo();
 
     // Add a custom section.
-    await gu.addNewSection('Custom', 'Table1');
+    await gu.addNewSection('Custom', 'Table1', { customWidget: /Custom URL/ });
     assert.isFalse(await gu.isSidePanelOpen('right'));
     await gu.undo();
 
     // Add a custom page.
-    await gu.addNewPage('Custom', 'Table1');
+    await gu.addNewPage('Custom', 'Table1', { customWidget: /Custom URL/ });
     assert.isFalse(await gu.isSidePanelOpen('right'));
     await gu.undo();
 
     // Now open the panel on the column tab.
-    const columnTab = async () => {
-      await gu.toggleSidePanel('right', 'open');
-      await driver.find('.test-right-tab-field').click();
-    };
-
-    await columnTab();
+    await gu.openColumnPanel();
 
     // Add a chart section.
     await gu.addNewSection('Chart', 'Table1');
@@ -56,7 +51,7 @@ describe('RightPanel', function() {
     assert.isTrue(await driver.find('.test-right-widget-title').isDisplayed());
     await gu.undo();
 
-    await columnTab();
+    await gu.openColumnPanel();
 
     // Add a chart page.
     await gu.addNewPage('Chart', 'Table1');
@@ -64,18 +59,18 @@ describe('RightPanel', function() {
     assert.isTrue(await driver.find('.test-right-widget-title').isDisplayed());
     await gu.undo();
 
-    await columnTab();
+    await gu.openColumnPanel();
 
     // Add a custom section.
-    await gu.addNewSection('Custom', 'Table1');
+    await gu.addNewSection('Custom', 'Table1', { customWidget: /Custom URL/ });
     assert.isTrue(await gu.isSidePanelOpen('right'));
     assert.isTrue(await driver.find('.test-right-widget-title').isDisplayed());
     await gu.undo();
 
-    await columnTab();
+    await gu.openColumnPanel();
 
     // Add a custom page.
-    await gu.addNewPage('Custom', 'Table1');
+    await gu.addNewPage('Custom', 'Table1', { customWidget: /Custom URL/ });
     assert.isTrue(await gu.isSidePanelOpen('right'));
     assert.isTrue(await driver.find('.test-right-widget-title').isDisplayed());
     await gu.undo();
diff --git a/test/nbrowser/SelectBy.ts b/test/nbrowser/SelectBy.ts
index 8fd90d3b..cb2df6db 100644
--- a/test/nbrowser/SelectBy.ts
+++ b/test/nbrowser/SelectBy.ts
@@ -100,7 +100,7 @@ describe("SelectBy", function() {
 
     // Create a page with with charts and custom widget and then check that no linking is offered
     await gu.addNewPage(/Chart/, /Table1/);
-    await gu.addNewSection(/Custom/, /Table2/);
+    await gu.addNewSection(/Custom/, /Table2/, {customWidget: /Custom URL/});
 
     // open add widget to page
     await driver.findWait('.test-dp-add-new', 2000).doClick();
diff --git a/test/nbrowser/ViewLayoutCollapse.ts b/test/nbrowser/ViewLayoutCollapse.ts
index 3c522373..13a3ee98 100644
--- a/test/nbrowser/ViewLayoutCollapse.ts
+++ b/test/nbrowser/ViewLayoutCollapse.ts
@@ -100,15 +100,15 @@ describe("ViewLayoutCollapse", function() {
 
     // Add custom section.
     await gu.addNewPage('Table', 'Companies');
-    await gu.addNewSection('Custom', 'Companies', { selectBy: 'COMPANIES'});
+    await gu.addNewSection('Custom', 'Companies', {selectBy: 'COMPANIES'});
 
     // Serve custom widget.
     const widgetServer = await serveSomething(app => {
       addStatic(app);
     });
     cleanup.addAfterAll(widgetServer.shutdown);
+    await gu.setCustomWidgetUrl(widgetServer.url + '/probe/index.html', {openGallery: false});
     await gu.openWidgetPanel();
-    await gu.setWidgetUrl(widgetServer.url + '/probe/index.html');
     await gu.widgetAccess(AccessLevel.full);
 
     // Collapse it.
@@ -139,15 +139,15 @@ describe("ViewLayoutCollapse", function() {
 
     // Add custom section.
     await gu.addNewPage('Table', 'Companies');
-    await gu.addNewSection('Custom', 'Companies', { selectBy: 'COMPANIES'});
+    await gu.addNewSection('Custom', 'Companies', {selectBy: 'COMPANIES'});
 
     // Serve custom widget.
     const widgetServer = await serveSomething(app => {
       addStatic(app);
     });
     cleanup.addAfterAll(widgetServer.shutdown);
+    await gu.setCustomWidgetUrl(widgetServer.url + '/probe/index.html', {openGallery: false});
     await gu.openWidgetPanel();
-    await gu.setWidgetUrl(widgetServer.url + '/probe/index.html');
     await gu.widgetAccess(AccessLevel.full);
 
     // Collapse it.
diff --git a/test/nbrowser/gristUtils.ts b/test/nbrowser/gristUtils.ts
index 6fc26dd6..25052905 100644
--- a/test/nbrowser/gristUtils.ts
+++ b/test/nbrowser/gristUtils.ts
@@ -3242,17 +3242,52 @@ export async function renameActiveTable(name: string) {
   await waitForServer();
 }
 
-export async function setWidgetUrl(url: string) {
-  await driver.find('.test-config-widget-url').click();
-  // First clear textbox.
-  await clearInput();
-  if (url) {
-    await sendKeys(url);
+export async function getCustomWidgetName() {
+  await openWidgetPanel();
+  return await driver.find('.test-config-widget-open-custom-widget-gallery').getText();
+}
+
+export async function getCustomWidgetInfo(info: 'description'|'developer'|'last-updated') {
+  await openWidgetPanel();
+  if (await driver.find('.test-config-widget-show-custom-widget-details').isPresent()) {
+    await driver.find('.test-config-widget-show-custom-widget-details').click();
   }
+  if (!await driver.find(`.test-config-widget-custom-widget-${info}`).isPresent()) {
+    return '';
+  }
+
+  return await driver.find(`.test-config-widget-custom-widget-${info}`).getText();
+}
+
+export async function openCustomWidgetGallery() {
+  await openWidgetPanel();
+  await driver.find('.test-config-widget-open-custom-widget-gallery').click();
+  await waitForServer();
+}
+
+interface SetWidgetOptions {
+  /** Defaults to `true`. */
+  openGallery?: boolean;
+}
+
+export async function setCustomWidgetUrl(url: string, options: SetWidgetOptions = {}) {
+  const {openGallery = true} = options;
+  if (openGallery) { await openCustomWidgetGallery(); }
+  await driver.find('.test-custom-widget-gallery-custom-url').click();
+  await clearInput();
+  if (url) { await sendKeys(url); }
   await sendKeys(Key.ENTER);
   await waitForServer();
 }
 
+export async function setCustomWidget(content: string|RegExp, options: SetWidgetOptions = {}) {
+  const {openGallery = true} = options;
+  if (openGallery) { await openCustomWidgetGallery(); }
+  await driver.findContent('.test-custom-widget-gallery-widget', content).click();
+  await driver.find('.test-custom-widget-gallery-save').click();
+  await waitForServer();
+}
+
 type BehaviorActions = 'Clear and reset' | 'Convert column to data' | 'Clear and make into formula' |
                        'Convert columns to data';
 /**
diff --git a/test/nbrowser/gristWebDriverUtils.ts b/test/nbrowser/gristWebDriverUtils.ts
index a017e30d..9323b2c4 100644
--- a/test/nbrowser/gristWebDriverUtils.ts
+++ b/test/nbrowser/gristWebDriverUtils.ts
@@ -99,13 +99,14 @@ export class GristWebDriverUtils {
     tableRe: RegExp|string = '',
     options: PageWidgetPickerOptions = {}
   ) {
+    const {customWidget, dismissTips, dontAdd, selectBy, summarize, tableName} = options;
     const driver = this.driver;
-    if (options.dismissTips) { await this.dismissBehavioralPrompts(); }
+    if (dismissTips) { await this.dismissBehavioralPrompts(); }
 
     // select right type
     await driver.findContent('.test-wselect-type', typeRe).doClick();
 
-    if (options.dismissTips) { await this.dismissBehavioralPrompts(); }
+    if (dismissTips) { await this.dismissBehavioralPrompts(); }
 
     if (tableRe) {
       const tableEl = driver.findContent('.test-wselect-table', tableRe);
@@ -118,34 +119,32 @@ export class GristWebDriverUtils {
       // let's select table
       await tableEl.click();
 
-      if (options.dismissTips) { await this.dismissBehavioralPrompts(); }
+      if (dismissTips) { await this.dismissBehavioralPrompts(); }
 
       const pivotEl = tableEl.find('.test-wselect-pivot');
       if (await pivotEl.isPresent()) {
-        await this.toggleSelectable(pivotEl, Boolean(options.summarize));
+        await this.toggleSelectable(pivotEl, Boolean(summarize));
       }
 
-      if (options.summarize) {
+      if (summarize) {
         for (const columnEl of await driver.findAll('.test-wselect-column')) {
           const label = await columnEl.getText();
           // TODO: Matching cols with regexp calls for trouble and adds no value. I think function should be
           // rewritten using string matching only.
-          const goal = Boolean(options.summarize.find(r => label.match(r)));
+          const goal = Boolean(summarize.find(r => label.match(r)));
           await this.toggleSelectable(columnEl, goal);
         }
       }
 
-      if (options.selectBy) {
+      if (selectBy) {
         // select link
         await driver.find('.test-wselect-selectby').doClick();
-        await driver.findContent('.test-wselect-selectby option', options.selectBy).doClick();
+        await driver.findContent('.test-wselect-selectby option', selectBy).doClick();
       }
     }
 
 
-    if (options.dontAdd) {
-      return;
-    }
+    if (dontAdd) { return; }
 
     // add the widget
     await driver.find('.test-wselect-addBtn').doClick();
@@ -154,14 +153,20 @@ export class GristWebDriverUtils {
     const prompts = await driver.findAll(".test-modal-prompt");
     const prompt = prompts[0];
     if (prompt) {
-      if (options.tableName) {
+      if (tableName) {
         await prompt.doClear();
         await prompt.click();
-        await driver.sendKeys(options.tableName);
+        await driver.sendKeys(tableName);
       }
       await driver.find(".test-modal-confirm").click();
     }
 
+    if (customWidget) {
+      await this.waitForServer();
+      await driver.findContent('.test-custom-widget-gallery-widget-name', customWidget).click();
+      await driver.find('.test-custom-widget-gallery-save').click();
+    }
+
     await this.waitForServer();
   }
 
@@ -269,4 +274,6 @@ export interface PageWidgetPickerOptions {
   dontAdd?: boolean;
   /** If true, dismiss any tooltips that are shown. */
   dismissTips?: boolean;
+  /** Optional pattern of custom widget name to select in the gallery. */
+  customWidget?: RegExp|string;
 }

From 3e70a777299d1b189386ef275b863cb7c2798caa Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= <jordigh@octave.org>
Date: Wed, 7 Aug 2024 13:20:20 -0400
Subject: [PATCH 132/145] (core) test: move gen-server tests into core

Summary:
These are tests that we just never moved into the public
repo. It's just a small chore to make them public.

Test Plan: Make sure the tests still pass

Reviewers: jarek

Reviewed By: jarek

Differential Revision: https://phab.getgrist.com/D4311
---
 test/gen-server/lib/DocApiForwarder.ts  | 248 +++++++++++
 test/gen-server/lib/DocWorkerMap.ts     | 511 +++++++++++++++++++++-
 test/gen-server/lib/HealthCheck.ts      | 116 +++++
 test/gen-server/lib/HomeDBManager.ts    | 417 ++++++++++++++++++
 test/gen-server/lib/Housekeeper.ts      | 207 +++++++++
 test/gen-server/lib/emails.ts           | 160 +++++++
 test/gen-server/lib/everyone.ts         | 179 ++++++++
 test/gen-server/lib/limits.ts           | 553 ++++++++++++++++++++++++
 test/gen-server/lib/listing.ts          | 196 +++++++++
 test/gen-server/lib/mergedOrgs.ts       | 104 +++++
 test/gen-server/lib/prefs.ts            | 132 ++++++
 test/gen-server/lib/previewer.ts        | 135 ++++++
 test/gen-server/lib/removedAt.ts        | 440 +++++++++++++++++++
 test/gen-server/lib/scrubUserFromOrg.ts | 453 +++++++++++++++++++
 test/gen-server/lib/suspension.ts       |  55 +++
 test/gen-server/lib/urlIds.ts           | 131 ++++++
 16 files changed, 4028 insertions(+), 9 deletions(-)
 create mode 100644 test/gen-server/lib/DocApiForwarder.ts
 create mode 100644 test/gen-server/lib/HealthCheck.ts
 create mode 100644 test/gen-server/lib/HomeDBManager.ts
 create mode 100644 test/gen-server/lib/Housekeeper.ts
 create mode 100644 test/gen-server/lib/emails.ts
 create mode 100644 test/gen-server/lib/everyone.ts
 create mode 100644 test/gen-server/lib/limits.ts
 create mode 100644 test/gen-server/lib/listing.ts
 create mode 100644 test/gen-server/lib/mergedOrgs.ts
 create mode 100644 test/gen-server/lib/prefs.ts
 create mode 100644 test/gen-server/lib/previewer.ts
 create mode 100644 test/gen-server/lib/removedAt.ts
 create mode 100644 test/gen-server/lib/scrubUserFromOrg.ts
 create mode 100644 test/gen-server/lib/suspension.ts
 create mode 100644 test/gen-server/lib/urlIds.ts

diff --git a/test/gen-server/lib/DocApiForwarder.ts b/test/gen-server/lib/DocApiForwarder.ts
new file mode 100644
index 00000000..a9852a3d
--- /dev/null
+++ b/test/gen-server/lib/DocApiForwarder.ts
@@ -0,0 +1,248 @@
+import { delay } from 'app/common/delay';
+import { createDummyGristServer } from 'app/server/lib/GristServer';
+import axios, { AxiosResponse } from 'axios';
+import { fromCallback } from "bluebird";
+import { assert } from 'chai';
+import express = require("express");
+import FormData from 'form-data';
+import { Server } from 'http';
+import defaultsDeep = require('lodash/defaultsDeep');
+import morganLogger from 'morgan';
+import { AddressInfo } from 'net';
+import sinon = require("sinon");
+
+import { createInitialDb, removeConnection, setUpDB } from "test/gen-server/seed";
+import { configForUser } from 'test/gen-server/testUtils';
+
+import { DocApiForwarder } from "app/gen-server/lib/DocApiForwarder";
+import { DocWorkerMap, getDocWorkerMap } from "app/gen-server/lib/DocWorkerMap";
+import { HomeDBManager } from "app/gen-server/lib/homedb/HomeDBManager";
+import { addRequestUser } from 'app/server/lib/Authorizer';
+import { jsonErrorHandler } from 'app/server/lib/expressWrap';
+import log from 'app/server/lib/log';
+import * as testUtils from 'test/server/testUtils';
+
+
+const chimpy = configForUser('Chimpy');
+const kiwi = configForUser('kiwi');
+
+const logToConsole = false;
+
+async function createServer(app: express.Application, name: string) {
+  let server: Server;
+  if (logToConsole) {
+    app.use(morganLogger((...args: any[]) => {
+      return `${log.timestamp()} ${name} ${morganLogger.dev(...args)}`;
+    }));
+  }
+  app.set('port', 0);
+  await fromCallback((cb: any) => server = app.listen(app.get('port'), 'localhost', cb));
+  log.info(`${name} listening ${getUrl(server!)}`);
+  return server!;
+}
+
+function getUrl(server: Server) {
+  return `http://localhost:${(server.address() as AddressInfo).port}`;
+}
+
+describe('DocApiForwarder', function() {
+
+  testUtils.setTmpLogLevel('error');
+
+  let homeServer: Server;
+  let docWorker: Server;
+  let resp: AxiosResponse;
+  let homeUrl: string;
+  let dbManager: HomeDBManager;
+  const docWorkerStub = sinon.stub();
+
+  before(async function() {
+    setUpDB(this);
+    dbManager = new HomeDBManager();
+    await dbManager.connect();
+    await createInitialDb(dbManager.connection);
+    await dbManager.initializeSpecialIds();
+
+    // create cheap doc worker
+    let app = express();
+    docWorker = await createServer(app, 'docw');
+    app.use(express.json());
+    app.use(docWorkerStub);
+
+    // create cheap home server
+    app = express();
+    homeServer = await createServer(app, 'home');
+    homeUrl = getUrl(homeServer);
+
+    // stubs doc worker map
+    const docWorkerMapStub = sinon.createStubInstance(DocWorkerMap);
+    docWorkerMapStub.assignDocWorker.returns(Promise.resolve({
+      docWorker: {
+        internalUrl: getUrl(docWorker) + '/dw/foo',
+        publicUrl: '',
+        id: '',
+      },
+      docMD5: null,
+      isActive: true,
+    }));
+
+    // create and register forwarder
+    const docApiForwarder = new DocApiForwarder(docWorkerMapStub, dbManager, null as any);
+    app.use("/api", addRequestUser.bind(null, dbManager, getDocWorkerMap().getPermitStore('internal'),
+                                        {gristServer: createDummyGristServer()} as any));
+    docApiForwarder.addEndpoints(app);
+    app.use('/api', jsonErrorHandler);
+  });
+
+  after(async function() {
+    await removeConnection();
+    homeServer.close();
+    docWorker.close();
+    dbManager.flushDocAuthCache();    // To avoid hanging up exit from tests.
+  });
+
+  beforeEach(() => {
+    docWorkerStub.resetHistory();
+    docWorkerStub.callsFake((req: any, res: any) => res.status(200).json('mango tree'));
+  });
+
+  it('should forward GET /api/docs/:did/tables/:tid/data', async function() {
+    resp = await axios.get(`${homeUrl}/api/docs/sampledocid_16/tables/table1/data`, chimpy);
+    assert.equal(resp.status, 200);
+    assert.equal(resp.data, 'mango tree');
+    assert(docWorkerStub.calledOnce);
+    const req = docWorkerStub.getCall(0).args[0];
+    assert.equal(req.get('Authorization'), 'Bearer api_key_for_chimpy');
+    assert.equal(req.get('Content-Type'), 'application/json');
+    assert.equal(req.originalUrl, '/dw/foo/api/docs/sampledocid_16/tables/table1/data');
+    assert.equal(req.method, 'GET');
+  });
+
+  it('should forward GET /api/docs/:did/tables/:tid/data?filter=<...>', async function() {
+    const filter = encodeURIComponent(JSON.stringify({FOO: ['bar']})); // => %7B%22FOO%22%3A%5B%22bar%22%5D%7D
+    resp = await axios.get(`${homeUrl}/api/docs/sampledocid_16/tables/table1/data?filter=${filter}`, chimpy);
+    assert.equal(resp.status, 200);
+    assert.equal(resp.data, 'mango tree');
+    assert(docWorkerStub.calledOnce);
+    const req = docWorkerStub.getCall(0).args[0];
+    assert.equal(req.get('Authorization'), 'Bearer api_key_for_chimpy');
+    assert.equal(req.get('Content-Type'), 'application/json');
+    assert.equal(req.originalUrl,
+                 '/dw/foo/api/docs/sampledocid_16/tables/table1/data?filter=%7B%22FOO%22%3A%5B%22bar%22%5D%7D');
+    assert.equal(req.method, 'GET');
+  });
+
+  it('should deny user without view permissions', async function() {
+    resp = await axios.get(`${homeUrl}/api/docs/sampledocid_13/tables/table1/data`, kiwi);
+    assert.equal(resp.status, 403);
+    assert.deepEqual(resp.data, {error: 'No view access'});
+    assert.equal(docWorkerStub.callCount, 0);
+  });
+
+
+  it('should forward POST /api/docs/:did/tables/:tid/data', async function() {
+    resp = await axios.post(`${homeUrl}/api/docs/sampledocid_16/tables/table1/data`, {message: 'golden pears'}, chimpy);
+    assert.equal(resp.status, 200);
+    assert.equal(resp.data, 'mango tree');
+    assert(docWorkerStub.calledOnce);
+    const req = docWorkerStub.getCall(0).args[0];
+    assert.equal(req.get('Authorization'), 'Bearer api_key_for_chimpy');
+    assert.equal(req.get('Content-Type'), 'application/json');
+    assert.equal(req.originalUrl, '/dw/foo/api/docs/sampledocid_16/tables/table1/data');
+    assert.equal(req.method, 'POST');
+    assert.deepEqual(req.body, {message: 'golden pears'});
+  });
+
+
+  it('should forward PATCH /api/docs/:did/tables/:tid/data', async function() {
+    resp = await axios.patch(`${homeUrl}/api/docs/sampledocid_16/tables/table1/data`,
+                             {message: 'golden pears'}, chimpy);
+    assert.equal(resp.status, 200);
+    assert.equal(resp.data, 'mango tree');
+    assert(docWorkerStub.calledOnce);
+    const req = docWorkerStub.getCall(0).args[0];
+    assert.equal(req.get('Authorization'), 'Bearer api_key_for_chimpy');
+    assert.equal(req.get('Content-Type'), 'application/json');
+    assert.equal(req.originalUrl, '/dw/foo/api/docs/sampledocid_16/tables/table1/data');
+    assert.equal(req.method, 'PATCH');
+    assert.deepEqual(req.body, {message: 'golden pears'});
+  });
+
+  it('should forward POST /api/docs/:did/attachments', async function() {
+    const formData = new FormData();
+    formData.append('upload', 'abcdef', "hello.png");
+    resp = await axios.post(`${homeUrl}/api/docs/sampledocid_16/attachments`, formData,
+      defaultsDeep({headers: formData.getHeaders()}, chimpy));
+    assert.equal(resp.status, 200);
+    assert.deepEqual(resp.headers['content-type'], 'application/json; charset=utf-8');
+    assert.deepEqual(resp.data, 'mango tree');
+    assert(docWorkerStub.calledOnce);
+    const req = docWorkerStub.getCall(0).args[0];
+    assert.equal(req.get('Authorization'), 'Bearer api_key_for_chimpy');
+    assert.match(req.get('Content-Type'), /^multipart\/form-data; boundary=/);
+    assert.equal(req.originalUrl, '/dw/foo/api/docs/sampledocid_16/attachments');
+    assert.equal(req.method, 'POST');
+  });
+
+  it('should forward GET /api/docs/:did/attachments/:attId/download', async function() {
+    docWorkerStub.callsFake((_req: any, res: any) =>
+      res.status(200)
+        .type('.png')
+        .set('Content-Disposition', 'attachment; filename="hello.png"')
+        .set('Cache-Control', 'private, max-age=3600')
+        .send(Buffer.from('abcdef')));
+    resp = await axios.get(`${homeUrl}/api/docs/sampledocid_16/attachments/123/download`, chimpy);
+    assert.equal(resp.status, 200);
+    assert.deepEqual(resp.headers['content-type'], 'image/png');
+    assert.deepEqual(resp.headers['content-disposition'], 'attachment; filename="hello.png"');
+    assert.deepEqual(resp.headers['cache-control'], 'private, max-age=3600');
+    assert.deepEqual(resp.data, 'abcdef');
+    assert(docWorkerStub.calledOnce);
+    const req = docWorkerStub.getCall(0).args[0];
+    assert.equal(req.get('Authorization'), 'Bearer api_key_for_chimpy');
+    assert.equal(req.get('Content-Type'), 'application/json');
+    assert.equal(req.originalUrl, '/dw/foo/api/docs/sampledocid_16/attachments/123/download');
+    assert.equal(req.method, 'GET');
+  });
+
+  it('should forward error message on failure', async function() {
+    docWorkerStub.callsFake((_req: any, res: any) => res.status(500).send({error: 'internal error'}));
+    resp = await axios.get(`${homeUrl}/api/docs/sampledocid_16/tables/table1/data`, chimpy);
+    assert.equal(resp.status, 500);
+    assert.deepEqual(resp.data, {error: 'internal error'});
+    assert(docWorkerStub.calledOnce);
+    const req = docWorkerStub.getCall(0).args[0];
+    assert.equal(req.get('Authorization'), 'Bearer api_key_for_chimpy');
+    assert.equal(req.get('Content-Type'), 'application/json');
+    assert.equal(req.originalUrl, '/dw/foo/api/docs/sampledocid_16/tables/table1/data');
+    assert.equal(req.method, 'GET');
+  });
+
+  it('should notice aborted requests and cancel forwarded ones', async function() {
+    let requestReceived: Function;
+    let closeReceived: Function;
+    let requestDone: Function;
+    const checkIsClosed = sinon.spy();
+    const promiseForRequestReceived = new Promise(r => { requestReceived = r; });
+    const promiseForCloseReceived = new Promise(r => { closeReceived = r; });
+    const promiseForRequestDone = new Promise(r => { requestDone = r; });
+    docWorkerStub.callsFake(async (req: any, res: any) => {
+      req.on('close', closeReceived);
+      requestReceived();
+      await Promise.race([promiseForCloseReceived, delay(100)]);
+      checkIsClosed(req.closed || req.aborted);
+      res.status(200).json('fig tree?');
+      requestDone();
+    });
+    const CancelToken = axios.CancelToken;
+    const source = CancelToken.source();
+    const response = axios.get(`${homeUrl}/api/docs/sampledocid_16/tables/table1/data`,
+      {...chimpy, cancelToken: source.token});
+    await promiseForRequestReceived;
+    source.cancel('cancelled for testing');
+    await assert.isRejected(response, /cancelled for testing/);
+    await promiseForRequestDone;
+    sinon.assert.calledOnce(checkIsClosed);
+    assert.deepEqual(checkIsClosed.args, [[true]]);
+  });
+});
diff --git a/test/gen-server/lib/DocWorkerMap.ts b/test/gen-server/lib/DocWorkerMap.ts
index 56421f6f..73b7d21c 100644
--- a/test/gen-server/lib/DocWorkerMap.ts
+++ b/test/gen-server/lib/DocWorkerMap.ts
@@ -1,14 +1,507 @@
-// Test for DocWorkerMap.ts
-
-import { DocWorkerMap } from 'app/gen-server/lib/DocWorkerMap';
-import { DocWorkerInfo } from 'app/server/lib/DocWorkerMap';
-import {expect} from 'chai';
+import {DocWorkerMap, getDocWorkerMap} from 'app/gen-server/lib/DocWorkerMap';
+import {DocStatus, DocWorkerInfo, IDocWorkerMap} from 'app/server/lib/DocWorkerMap';
+import {FlexServer} from 'app/server/lib/FlexServer';
+import {Permit} from 'app/server/lib/Permit';
+import {main as mergedServerMain} from 'app/server/mergedServerMain';
+import {delay, promisifyAll} from 'bluebird';
+import {assert, expect} from 'chai';
+import {countBy, values} from 'lodash';
+import {createClient, RedisClient} from 'redis';
+import {TestSession} from 'test/gen-server/apiUtils';
+import {createInitialDb, removeConnection, setUpDB} from 'test/gen-server/seed';
 import sinon from 'sinon';
+import * as testUtils from 'test/server/testUtils';
 
-describe('DocWorkerMap', () => {
-  const sandbox = sinon.createSandbox();
-  afterEach(() => {
-    sandbox.restore();
+promisifyAll(RedisClient.prototype);
+
+describe('DocWorkerMap', function() {
+
+  let cli: RedisClient;
+
+  testUtils.setTmpLogLevel('error');
+
+  before(async function() {
+    if (!process.env.TEST_REDIS_URL) { this.skip(); }
+    cli = createClient(process.env.TEST_REDIS_URL);
+    await cli.flushdbAsync();
+  });
+
+  after(async function() {
+    if (cli) { await cli.quitAsync(); }
+  });
+
+  beforeEach(async function() {
+    if (cli) { await cli.delAsync('groups'); }
+  });
+
+  afterEach(async function() {
+    if (cli) { await cli.flushdbAsync(); }
+  });
+
+  it('can assign a worker when available', async function() {
+    const workers = new DocWorkerMap([cli]);
+
+    // No assignment without workers available
+    await assert.isRejected(workers.assignDocWorker('a-doc'), /no doc workers/);
+
+    // Add a worker
+    await workers.addWorker({id: 'worker1', internalUrl: 'internal', publicUrl: 'public'});
+
+    // Still no assignment
+    await assert.isRejected(workers.assignDocWorker('a-doc'), /no doc workers/);
+
+    // Make worker available
+    await workers.setWorkerAvailability('worker1', true);
+
+    // That worker gets assigned
+    const worker = await workers.assignDocWorker('a-doc');
+    assert.equal(worker.docWorker.id, 'worker1');
+
+    // That assignment is remembered
+    let w = await workers.getDocWorker('a-doc');
+    assert.equal(w && w.docWorker.id, 'worker1');
+
+    // Make worker unavailable for assigment
+    await workers.setWorkerAvailability('worker1', false);
+
+    // Existing assignment remains
+    w = await workers.getDocWorker('a-doc');
+    assert.equal(w && w.docWorker.id, 'worker1');
+
+    // Remove worker
+    await workers.removeWorker('worker1');
+
+    // Assignment is gone away
+    w = await workers.getDocWorker('a-doc');
+    assert.equal(w, null);
+  });
+
+  it('can release assignments', async function() {
+    const workers = new DocWorkerMap([cli]);
+
+    await workers.addWorker({id: 'worker1', internalUrl: 'internal', publicUrl: 'public'});
+    await workers.addWorker({id: 'worker2', internalUrl: 'internal', publicUrl: 'public'});
+
+    await workers.setWorkerAvailability('worker1', true);
+
+    let assignment: DocStatus|null = await workers.assignDocWorker('a-doc');
+    assert.equal(assignment.docWorker.id, 'worker1');
+
+    await workers.setWorkerAvailability('worker2', true);
+    await workers.setWorkerAvailability('worker1', false);
+
+    assignment = await workers.getDocWorker('a-doc');
+    assert.equal(assignment!.docWorker.id, 'worker1');
+
+    await workers.releaseAssignment('worker1', 'a-doc');
+
+    assignment = await workers.getDocWorker('a-doc');
+    assert.equal(assignment, null);
+
+    assignment = await workers.assignDocWorker('a-doc');
+    assert.equal(assignment.docWorker.id, 'worker2');
+  });
+
+  it('can assign multiple workers', async function() {
+    this.timeout(5000);   // Be more generous than 2s default, since this normally takes over 1s.
+
+    const workers = new DocWorkerMap([cli]);
+
+    // Make some workers available
+    const W = 4;
+    for (let i = 0; i < W; i++) {
+      await workers.addWorker({id: `worker${i}`, internalUrl: 'internal', publicUrl: 'public'});
+      await workers.setWorkerAvailability(`worker${i}`, true);
+    }
+
+    // Assign some docs
+    const N = 100;
+    const docs: string[] = [];
+    const docWorkers: string[] = [];
+    for (let i = 0; i < N; i++) {
+      const name = `a-doc-${i}`;
+      docs.push(name);
+      const w = await workers.assignDocWorker(name);
+      docWorkers.push(w.docWorker.id);
+    }
+
+    // Check assignment looks plausible (random, so will fail with low prob)
+    const counts = countBy(docWorkers);
+    // Say over half the workers got assigned
+    assert.isAbove(values(counts).length, W / 2);
+    // Say no worker got over half the work
+    const highs = values(counts).filter((k, v) => v > N / 2);
+    assert.equal(highs.length, 0);
+
+    // Check assignments stick
+    for (let i = 0; i < N; i++) {
+      const name = docs[i];
+      const w = await workers.getDocWorker(name);
+      assert.equal(w && w.docWorker.id, docWorkers[i]);
+    }
+
+    // Check assignments drop out as workers are removed
+    let remaining = N;
+    for (const w of Object.keys(counts)) {
+      await workers.removeWorker(w);
+      remaining -= counts[w];
+      let ct = 0;
+      for (const name of docs) {
+        if (null !== await workers.getDocWorker(name)) { ct++; }
+      }
+      assert.equal(remaining, ct);
+    }
+    assert.equal(remaining, 0);
+  });
+
+  it('can elect workers to groups', async function() {
+    this.timeout(5000);
+
+    // Say we want one worker reserved for "blizzard" and two for "funkytown"
+    await cli.hmsetAsync('groups', {
+      blizzard: 1,
+      funkytown: 2,
+    });
+    for (let i = 0; i < 20; i++) {
+      await cli.setAsync(`doc-blizzard${i}-group`, 'blizzard');
+      await cli.setAsync(`doc-funkytown${i}-group`, 'funkytown');
+    }
+    let workers = new DocWorkerMap([cli], 'ver1');
+    for (let i = 0; i < 5; i++) {
+      await workers.addWorker({id: `worker${i}`, internalUrl: 'internal', publicUrl: 'public'});
+      await workers.setWorkerAvailability(`worker${i}`, true);
+    }
+    let elections = await cli.hgetallAsync('elections-ver1');
+    assert.deepEqual(elections, { blizzard: '["worker0"]', funkytown: '["worker1","worker2"]' });
+    assert.sameMembers(await cli.smembersAsync('workers-available-blizzard'), ['worker0']);
+    assert.sameMembers(await cli.smembersAsync('workers-available-funkytown'), ['worker1', 'worker2']);
+    assert.sameMembers(await cli.smembersAsync('workers-available-default'), ['worker3', 'worker4']);
+    assert.sameMembers(await cli.smembersAsync('workers-available'),
+                       ['worker0', 'worker1', 'worker2', 'worker3', 'worker4']);
+    for (let i = 0; i < 20; i++) {
+      const assignment = await workers.assignDocWorker(`blizzard${i}`);
+      assert.equal(assignment.docWorker.id, 'worker0');
+    }
+    for (let i = 0; i < 20; i++) {
+      const assignment = await workers.assignDocWorker(`funkytown${i}`);
+      assert.include(['worker1', 'worker2'], assignment.docWorker.id);
+   }
+    for (let i = 0; i < 20; i++) {
+      const assignment = await workers.assignDocWorker(`random${i}`);
+      assert.include(['worker3', 'worker4'], assignment.docWorker.id);
+    }
+
+    // suppose worker0 dies, and worker5 is added to replace it
+    await workers.removeWorker('worker0');
+    await workers.addWorker({id: `worker5`, internalUrl: 'internal', publicUrl: 'public'});
+    await workers.setWorkerAvailability('worker5', true);
+    for (let i = 0; i < 20; i++) {
+      const assignment = await workers.assignDocWorker(`blizzard${i}`);
+      assert.equal(assignment.docWorker.id, 'worker5');
+    }
+
+    // suppose worker1 dies, and worker6 is added to replace it
+    await workers.removeWorker('worker1');
+    await workers.addWorker({id: `worker6`, internalUrl: 'internal', publicUrl: 'public'});
+    await workers.setWorkerAvailability('worker6', true);
+    for (let i = 0; i < 20; i++) {
+      const assignment = await workers.assignDocWorker(`funkytown${i}`);
+      assert.include(['worker2', 'worker6'], assignment.docWorker.id);
+    }
+
+    // suppose we add a new deployment...
+    workers = new DocWorkerMap([cli], 'ver2');
+    for (let i = 0; i < 5; i++) {
+      await workers.addWorker({id: `worker${i}_v2`, internalUrl: 'internal', publicUrl: 'public'});
+      await workers.setWorkerAvailability(`worker${i}_v2`, true);
+    }
+    assert.sameMembers(await cli.smembersAsync('workers-available-blizzard'),
+                       ['worker5', 'worker0_v2']);
+    assert.sameMembers(await cli.smembersAsync('workers-available-funkytown'),
+                       ['worker2', 'worker6', 'worker1_v2', 'worker2_v2']);
+    assert.sameMembers(await cli.smembersAsync('workers-available-default'),
+                       ['worker3', 'worker4', 'worker3_v2', 'worker4_v2']);
+    assert.sameMembers(await cli.smembersAsync('workers-available'),
+                       ['worker2', 'worker3', 'worker4', 'worker5', 'worker6',
+                        'worker0_v2', 'worker1_v2', 'worker2_v2', 'worker3_v2', 'worker4_v2']);
+
+    // ...and then remove the old one
+    workers = new DocWorkerMap([cli], 'ver1');
+    for (let i = 0; i < 7; i++) {
+      await workers.removeWorker(`worker${i}`);
+    }
+
+    // check everything looks as expected
+    workers = new DocWorkerMap([cli], 'ver2');
+    elections = await cli.hgetallAsync('elections-ver2');
+    assert.deepEqual(elections, { blizzard: '["worker0_v2"]',
+                                  funkytown: '["worker1_v2","worker2_v2"]' });
+    assert.sameMembers(await cli.smembersAsync('workers-available-blizzard'), ['worker0_v2']);
+    assert.sameMembers(await cli.smembersAsync('workers-available-funkytown'), ['worker1_v2', 'worker2_v2']);
+    assert.sameMembers(await cli.smembersAsync('workers-available-default'), ['worker3_v2', 'worker4_v2']);
+    assert.sameMembers(await cli.smembersAsync('workers-available'),
+                       ['worker0_v2', 'worker1_v2', 'worker2_v2', 'worker3_v2', 'worker4_v2']);
+    for (let i = 0; i < 20; i++) {
+      const assignment = await workers.assignDocWorker(`blizzard${i}`);
+      assert.equal(assignment.docWorker.id, 'worker0_v2');
+    }
+    for (let i = 0; i < 20; i++) {
+      const assignment = await workers.assignDocWorker(`funkytown${i}`);
+      assert.include(['worker1_v2', 'worker2_v2'], assignment.docWorker.id);
+   }
+    for (let i = 0; i < 20; i++) {
+      const assignment = await workers.assignDocWorker(`random${i}`);
+      assert.include(['worker3_v2', 'worker4_v2'], assignment.docWorker.id);
+    }
+
+    // check everything about previous deployment got cleaned up
+    assert.equal(await cli.hgetallAsync('elections-ver1'), null);
+  });
+
+  it('can assign workers to groups', async function() {
+    this.timeout(5000);
+    const workers = new DocWorkerMap([cli], 'ver1');
+
+    // Register a few regular workers.
+    for (let i = 0; i < 3; i++) {
+      await workers.addWorker({id: `worker${i}`, internalUrl: 'internal', publicUrl: 'public'});
+      await workers.setWorkerAvailability(`worker${i}`, true);
+    }
+
+    // Register a worker in a special group.
+    await workers.addWorker({id: 'worker_secondary', internalUrl: 'internal', publicUrl: 'public',
+                             group: 'secondary'});
+    await workers.setWorkerAvailability('worker_secondary', true);
+
+    // Check that worker lists look sane.
+    assert.sameMembers(await cli.smembersAsync('workers'),
+                       ['worker0', 'worker1', 'worker2', 'worker_secondary']);
+    assert.sameMembers(await cli.smembersAsync('workers-available'),
+                       ['worker0', 'worker1', 'worker2']);
+    assert.sameMembers(await cli.smembersAsync('workers-available-default'),
+                       ['worker0', 'worker1', 'worker2']);
+    assert.sameMembers(await cli.smembersAsync('workers-available-secondary'),
+                       ['worker_secondary']);
+
+    // Check that worker-*-group keys are as expected.
+    assert.equal(await cli.getAsync('worker-worker_secondary-group'), 'secondary');
+    assert.equal(await cli.getAsync('worker-worker0-group'), null);
+
+    // Check that a doc for the special group is assigned to the correct worker.
+    await cli.setAsync('doc-funkydoc-group', 'secondary');
+    assert.equal((await workers.assignDocWorker('funkydoc')).docWorker.id, 'worker_secondary');
+
+    // Check that other docs don't end up on the special group's worker.
+    for (let i = 0; i < 50; i++) {
+      assert.match((await workers.assignDocWorker(`normaldoc${i}`)).docWorker.id,
+                   /^worker\d$/);
+    }
+  });
+
+  it('can manage task election nominations', async function() {
+    this.timeout(5000);
+
+    const store = new DocWorkerMap([cli]);
+    // allocate two tasks
+    const task1 = await store.getElection('task1', 1000);
+    let task2 = await store.getElection('task2', 1000);
+    assert.notEqual(task1, null);
+    assert.notEqual(task2, null);
+    assert.notEqual(task1, task2);
+
+    // check tasks cannot be immediately reallocated
+    assert.equal(await store.getElection('task1', 1000), null);
+    assert.equal(await store.getElection('task2', 1000), null);
+
+    // try to remove both tasks with a key that is correct for just one of them.
+    await assert.isRejected(store.removeElection('task1', task2!), /could not remove/);
+    await store.removeElection('task2', task2!);
+
+    // check task2 is freed up by reallocating it
+    task2 = await store.getElection('task2', 3000);
+    assert.notEqual(task2, null);
+
+    await delay(1100);
+
+    // task1 should be free now, but not task2
+    const task1b = await store.getElection('task1', 1000);
+    assert.notEqual(task1b, null);
+    assert.notEqual(task1b, task1);
+    assert.equal(await store.getElection('task2', 1000), null);
+  });
+
+  it('can manage permits', async function() {
+    const store = new DocWorkerMap([cli], undefined, {permitMsec: 1000}).getPermitStore('1');
+
+    // Make a doc permit and a workspace permit
+    const permit1: Permit = {docId: 'docId1'};
+    const key1 = await store.setPermit(permit1);
+    assert(key1.startsWith('permit-1-'));
+    const permit2: Permit = {workspaceId: 99};
+    const key2 = await store.setPermit(permit2);
+    assert(key2.startsWith('permit-1-'));
+    assert.notEqual(key1, key2);
+
+    // Check we can read the permits back
+    assert.deepEqual(await store.getPermit(key1), permit1);
+    assert.deepEqual(await store.getPermit(key2), permit2);
+
+    // Check that random permit keys give nothing
+    await assert.isRejected(store.getPermit('dud'), /could not be read/);
+    assert.equal(await store.getPermit('permit-1-dud'), null);
+
+    // Check that we can remove a permit
+    await store.removePermit(key1);
+    assert.equal(await store.getPermit(key1), null);
+    assert.deepEqual(await store.getPermit(key2), permit2);
+
+    // Check that permits expire
+    await delay(1100);
+    assert.equal(await store.getPermit(key2), null);
+
+    // make sure permit stores are distinct
+    const store2 = new DocWorkerMap([cli], undefined, {permitMsec: 1000}).getPermitStore('2');
+    const key3 = await store2.setPermit(permit1);
+    assert(key3.startsWith('permit-2-'));
+    const fakeKey3 = key3.replace('permit-2-', 'permit-1-');
+    assert(fakeKey3.startsWith('permit-1-'));
+    assert.equal(await store.getPermit(fakeKey3), null);
+    await assert.isRejected(store.getPermit(key3), /could not be read/);
+    assert.deepEqual(await store2.getPermit(key3), permit1);
+    await assert.isRejected(store2.getPermit(fakeKey3), /could not be read/);
+  });
+
+  describe('group assignment', function() {
+    let servers: {[key: string]: FlexServer};
+    let workers: IDocWorkerMap;
+    before(async function() {
+      // Create a home server and some workers.
+      setUpDB(this);
+      await createInitialDb();
+      const opts = {logToConsole: false, externalStorage: false};
+      // We need to reset some environment variables - we do so
+      // naively, so throw if they are already set.
+      assert.equal(process.env.REDIS_URL, undefined);
+      assert.equal(process.env.GRIST_DOC_WORKER_ID, undefined);
+      assert.equal(process.env.GRIST_WORKER_GROUP, undefined);
+      process.env.REDIS_URL = process.env.TEST_REDIS_URL;
+
+      // Make home server.
+      const home = await mergedServerMain(0, ['home'], opts);
+
+      // Make a worker, not associated with any group.
+      process.env.GRIST_DOC_WORKER_ID = 'worker1';
+      const docs1 = await mergedServerMain(0, ['docs'], opts);
+
+      // Make a worker in "special" group.
+      process.env.GRIST_DOC_WORKER_ID = 'worker2';
+      process.env.GRIST_WORKER_GROUP = 'special';
+      const docs2 = await mergedServerMain(0, ['docs'], opts);
+
+      // Make two worker in "other" group.
+      process.env.GRIST_DOC_WORKER_ID = 'worker3';
+      process.env.GRIST_WORKER_GROUP = 'other';
+      const docs3 = await mergedServerMain(0, ['docs'], opts);
+      process.env.GRIST_DOC_WORKER_ID = 'worker4';
+      process.env.GRIST_WORKER_GROUP = 'other';
+      const docs4 = await mergedServerMain(0, ['docs'], opts);
+
+      servers = {home, docs1, docs2, docs3, docs4};
+      workers = getDocWorkerMap();
+    });
+
+    after(async function() {
+      if (servers) {
+        await Promise.all(Object.values(servers).map(server => server.close()));
+        await removeConnection();
+        delete process.env.REDIS_URL;
+        delete process.env.GRIST_DOC_WORKER_ID;
+        delete process.env.GRIST_WORKER_GROUP;
+        await workers.close();
+      }
+    });
+
+    it('can reassign documents between groups', async function() {
+      this.timeout(15000);
+
+      // Create a test documment.
+      const session = new TestSession(servers.home!);
+      const api = await session.createHomeApi('chimpy', 'nasa');
+      const supportApi = await session.createHomeApi('support', 'docs', true);
+      const ws1 = await api.newWorkspace({name: 'ws1'}, 'current');
+      const doc1 = await api.newDoc({name: 'doc1'}, ws1);
+
+      // Exercise it.
+      await api.getDocAPI(doc1).getRows('Table1');
+
+      // Check it is served by only unspecialized worker.
+      assert.equal((await workers.getDocWorker(doc1))?.docWorker.id, 'worker1');
+
+      // Set doc to "special" group.
+      await cli.setAsync(`doc-${doc1}-group`, 'special');
+
+      // Check doc gets reassigned to correct worker.
+      assert.equal(await (await api.testRequest(`${api.getBaseUrl()}/api/docs/${doc1}/assign`, {
+        method: 'POST'
+      })).json(), true);
+      await api.getDocAPI(doc1).getRows('Table1');
+      assert.equal((await workers.getDocWorker(doc1))?.docWorker.id, 'worker2');
+
+      // Set doc to "other" group.
+      await cli.setAsync(`doc-${doc1}-group`, 'other');
+
+      // Check doc gets reassigned to one of the correct workers.
+      assert.equal(await (await api.testRequest(`${api.getBaseUrl()}/api/docs/${doc1}/assign`, {
+        method: 'POST'
+      })).json(), true);
+      await api.getDocAPI(doc1).getRows('Table1');
+      assert.oneOf((await workers.getDocWorker(doc1))?.docWorker.id, ['worker3', 'worker4']);
+
+      // Remove doc from groups.
+      await cli.delAsync(`doc-${doc1}-group`);
+      assert.equal(await (await api.testRequest(`${api.getBaseUrl()}/api/docs/${doc1}/assign`, {
+        method: 'POST'
+      })).json(), true);
+      await api.getDocAPI(doc1).getRows('Table1');
+
+      // Check doc is again served by only unspecialized worker.
+      assert.equal((await workers.getDocWorker(doc1))?.docWorker.id, 'worker1');
+
+      // Check that hitting /assign without a change of group is reported as no-op (false).
+      assert.equal(await (await api.testRequest(`${api.getBaseUrl()}/api/docs/${doc1}/assign`, {
+        method: 'POST'
+      })).json(), false);
+
+      // Check that Chimpy can't use `group` param to update doc group prior to reassignment.
+      const urlWithGroup = new URL(`${api.getBaseUrl()}/api/docs/${doc1}/assign`);
+      urlWithGroup.searchParams.set('group', 'special');
+      assert.equal(await (await api.testRequest(urlWithGroup.toString(), {
+        method: 'POST'
+      })).json(), false);
+
+      // Check that support user can use `group` param in housekeeping endpoint to update
+      // doc group prior to reassignment.
+      const housekeepingUrl = new URL(`${api.getBaseUrl()}/api/housekeeping/docs/${doc1}/assign`);
+      housekeepingUrl.searchParams.set('group', 'special');
+      assert.equal(await (await supportApi.testRequest(housekeepingUrl.toString(), {
+        method: 'POST'
+      })).json(), true);
+      await api.getDocAPI(doc1).getRows('Table1');
+      assert.equal((await workers.getDocWorker(doc1))?.docWorker.id, 'worker2');
+
+      // Check that hitting housekeeping endpoint with the same group is reported as no-op (false).
+      assert.equal(await (await supportApi.testRequest(housekeepingUrl.toString(), {
+        method: 'POST'
+      })).json(), false);
+
+      // Check that specifying a blank group reverts back to the unspecialized worker.
+      housekeepingUrl.searchParams.set('group', '');
+      assert.equal(await (await supportApi.testRequest(housekeepingUrl.toString(), {
+        method: 'POST'
+      })).json(), true);
+      await api.getDocAPI(doc1).getRows('Table1');
+      assert.equal((await workers.getDocWorker(doc1))?.docWorker.id, 'worker1');
+    });
   });
 
   describe('isWorkerRegistered', () => {
diff --git a/test/gen-server/lib/HealthCheck.ts b/test/gen-server/lib/HealthCheck.ts
new file mode 100644
index 00000000..d2fa6441
--- /dev/null
+++ b/test/gen-server/lib/HealthCheck.ts
@@ -0,0 +1,116 @@
+import { assert } from 'chai';
+import fetch from 'node-fetch';
+import { TestServer } from 'test/gen-server/apiUtils';
+import { TcpForwarder } from 'test/server/tcpForwarder';
+import * as testUtils from 'test/server/testUtils';
+import { waitForIt } from 'test/server/wait';
+
+describe('HealthCheck', function() {
+  testUtils.setTmpLogLevel('error');
+
+  for (const serverType of ['home', 'docs'] as Array<'home'|'docs'>) {
+    describe(serverType, function() {
+      let server: TestServer;
+      let oldEnv: testUtils.EnvironmentSnapshot;
+      let redisForwarder: TcpForwarder;
+
+      before(async function() {
+        oldEnv = new testUtils.EnvironmentSnapshot();
+
+        // We set up Redis via a TcpForwarder, so that we can simulate disconnects.
+        if (!process.env.TEST_REDIS_URL) {
+          throw new Error("TEST_REDIS_URL is expected");
+        }
+        const redisUrl = new URL(process.env.TEST_REDIS_URL);
+        const redisPort = parseInt(redisUrl.port, 10) || 6379;
+        redisForwarder = new TcpForwarder(redisPort, redisUrl.host);
+        const forwarderPort = await redisForwarder.pickForwarderPort();
+        await redisForwarder.connect();
+
+        process.env.REDIS_URL = `redis://localhost:${forwarderPort}`;
+        server = new TestServer(this);
+        await server.start([serverType]);
+      });
+
+      after(async function() {
+        await server.stop();
+        await redisForwarder.disconnect();
+        oldEnv.restore();
+      });
+
+      it('has a working simple /status endpoint', async function() {
+        const result = await fetch(server.server.getOwnUrl() + '/status');
+        const text = await result.text();
+        assert.match(text, /Grist server.*alive/);
+        assert.notMatch(text, /db|redis/);
+        assert.equal(result.ok, true);
+        assert.equal(result.status, 200);
+      });
+
+      it('allows asking for db and redis status', async function() {
+        const result = await fetch(server.server.getOwnUrl() + '/status?db=1&redis=1&timeout=500');
+        assert.match(await result.text(), /Grist server.*alive.*db ok, redis ok/);
+        assert.equal(result.ok, true);
+        assert.equal(result.status, 200);
+      });
+
+      function blockPostgres(driver: any) {
+        // Make the database unhealthy by exausting the connection pool. This happens to be a way
+        // that has occurred in practice.
+        const blockers: Array<Promise<void>> = [];
+        const resolvers: Array<() => void> = [];
+        for (let i = 0; i < driver.master.options.max; i++) {
+          const promise = new Promise<void>((resolve) => { resolvers.push(resolve); });
+          blockers.push(server.dbManager.connection.transaction((manager) => promise));
+        }
+        return {
+          blockerPromise: Promise.all(blockers),
+          resolve: () => resolvers.forEach(resolve => resolve()),
+        };
+      }
+
+      it('reports error when database is unhealthy', async function() {
+        if (server.dbManager.connection.options.type !== 'postgres') {
+          // On postgres, we have a way to interfere with connections. Elsewhere (sqlite) it's not
+          // so obvious how to make DB unhealthy, so don't bother testing that.
+          this.skip();
+        }
+        this.timeout(5000);
+
+        const {blockerPromise, resolve} = blockPostgres(server.dbManager.connection.driver as any);
+        try {
+          const result = await fetch(server.server.getOwnUrl() + '/status?db=1&redis=1&timeout=500');
+          assert.match(await result.text(), /Grist server.*unhealthy.*db not ok, redis ok/);
+          assert.equal(result.ok, false);
+          assert.equal(result.status, 500);
+
+          // Plain /status endpoint should be unaffected.
+          assert.isTrue((await fetch(server.server.getOwnUrl() + '/status')).ok);
+        } finally {
+          resolve();
+          await blockerPromise;
+        }
+        assert.isTrue((await fetch(server.server.getOwnUrl() + '/status?db=1&redis=1&timeout=100')).ok);
+      });
+
+      it('reports error when redis is unhealthy', async function() {
+        this.timeout(5000);
+        await redisForwarder.disconnect();
+        try {
+          const result = await fetch(server.server.getOwnUrl() + '/status?db=1&redis=1&timeout=500');
+          assert.match(await result.text(), /Grist server.*unhealthy.*db ok, redis not ok/);
+          assert.equal(result.ok, false);
+          assert.equal(result.status, 500);
+
+          // Plain /status endpoint should be unaffected.
+          assert.isTrue((await fetch(server.server.getOwnUrl() + '/status')).ok);
+        } finally {
+          await redisForwarder.connect();
+        }
+        await waitForIt(async () =>
+          assert.isTrue((await fetch(server.server.getOwnUrl() + '/status?db=1&redis=1&timeout=100')).ok),
+          2000);
+      });
+    });
+  }
+});
diff --git a/test/gen-server/lib/HomeDBManager.ts b/test/gen-server/lib/HomeDBManager.ts
new file mode 100644
index 00000000..77778fef
--- /dev/null
+++ b/test/gen-server/lib/HomeDBManager.ts
@@ -0,0 +1,417 @@
+import {UserProfile} from 'app/common/LoginSessionAPI';
+import {HomeDBManager} from 'app/gen-server/lib/homedb/HomeDBManager';
+import {FREE_PLAN, STUB_PLAN, TEAM_PLAN} from 'app/common/Features';
+import {assert} from 'chai';
+import {TestServer} from 'test/gen-server/apiUtils';
+import * as testUtils from 'test/server/testUtils';
+import uuidv4 from 'uuid/v4';
+import omit = require('lodash/omit');
+
+const charonProfile = {email: 'charon@getgrist.com', name: 'Charon'};
+const chimpyProfile = {email: 'chimpy@getgrist.com', name: 'Chimpy'};
+const kiwiProfile = {email: 'kiwi@getgrist.com', name: 'Kiwi'};
+
+const teamOptions = {
+  setUserAsOwner: false, useNewPlan: true, product: TEAM_PLAN
+};
+
+describe('HomeDBManager', function() {
+
+  let server: TestServer;
+  let home: HomeDBManager;
+  testUtils.setTmpLogLevel('error');
+
+  before(async function() {
+    server = new TestServer(this);
+    await server.start();
+    home = server.dbManager;
+  });
+
+  after(async function() {
+    await server.stop();
+  });
+
+  it('can find existing user by email', async function() {
+    const user = await home.getUserByLogin('chimpy@getgrist.com');
+    assert.equal(user!.name, 'Chimpy');
+  });
+
+  it('can create new user by email, with personal org', async function() {
+    const profile = {email: 'unseen@getgrist.com', name: 'Unseen'};
+    const user = await home.getUserByLogin('unseen@getgrist.com', {profile});
+    assert.equal(user!.name, 'Unseen');
+    const orgs = await home.getOrgs(user!.id, null);
+    assert.isAtLeast(orgs.data!.length, 1);
+    assert.equal(orgs.data![0].name, 'Personal');
+    assert.equal(orgs.data![0].owner.name, 'Unseen');
+  });
+
+  it('parallel requests resulting in user creation give consistent results', async function() {
+    const profile = {
+      email: uuidv4() + "@getgrist.com",
+      name: "Testy McTestyTest"
+    };
+    const queries = [];
+    for (let i = 0; i < 100; i++) {
+      queries.push(home.getUserByLoginWithRetry(profile.email, {profile}));
+    }
+    const result = await Promise.all(queries);
+    const refUser = result[0];
+    assert(refUser && refUser.personalOrg && refUser.id && refUser.personalOrg.id);
+    result.forEach((user) => assert.deepEqual(refUser, user));
+  });
+
+  it('can accumulate profile information', async function() {
+    // log in without a name
+    let user = await home.getUserByLogin('unseen2@getgrist.com');
+    // name is blank
+    assert.equal(user!.name, '');
+    // log in with a name
+    const profile: UserProfile = {email: 'unseen2@getgrist.com', name: 'Unseen2'};
+    user = await home.getUserByLogin('unseen2@getgrist.com', {profile});
+    // name is now set
+    assert.equal(user!.name, 'Unseen2');
+    // log in without a name
+    user = await home.getUserByLogin('unseen2@getgrist.com');
+    // name is still set
+    assert.equal(user!.name, 'Unseen2');
+    // no picture yet
+    assert.equal(user!.picture, null);
+    // log in with picture link
+    profile.picture = 'http://picture.pic';
+    user = await home.getUserByLogin('unseen2@getgrist.com', {profile});
+    // now should have a picture link
+    assert.equal(user!.picture, 'http://picture.pic');
+    // log in without picture
+    user = await home.getUserByLogin('unseen2@getgrist.com');
+    // should still have picture link
+    assert.equal(user!.picture, 'http://picture.pic');
+  });
+
+  it('can add an org', async function() {
+    const user = await home.getUserByLogin('chimpy@getgrist.com');
+    const orgId = (await home.addOrg(user!, {name: 'NewOrg', domain: 'novel-org'}, teamOptions)).data!;
+    const org = await home.getOrg({userId: user!.id}, orgId);
+    assert.equal(org.data!.name, 'NewOrg');
+    assert.equal(org.data!.domain, 'novel-org');
+    assert.equal(org.data!.billingAccount.product.name, TEAM_PLAN);
+    await home.deleteOrg({userId: user!.id}, orgId);
+  });
+
+  it('creates default plan if defined', async function() {
+    const user = await home.getUserByLogin('chimpy@getgrist.com');
+    const oldEnv = new testUtils.EnvironmentSnapshot();
+    try {
+      // Set the default product to be the free plan.
+      process.env.GRIST_DEFAULT_PRODUCT = FREE_PLAN;
+      let orgId = (await home.addOrg(user!, {name: 'NewOrg', domain: 'novel-org'}, {
+        setUserAsOwner: false,
+        useNewPlan: true,
+        // omit plan, to use a default one (teamInitial)
+        // it will either be 'stub' or anything set in GRIST_DEFAULT_PRODUCT
+      })).data!;
+      let org = await home.getOrg({userId: user!.id}, orgId);
+      assert.equal(org.data!.name, 'NewOrg');
+      assert.equal(org.data!.domain, 'novel-org');
+      assert.equal(org.data!.billingAccount.product.name, FREE_PLAN);
+      await home.deleteOrg({userId: user!.id}, orgId);
+
+      // Now remove the default product, and check that the default plan is used.
+      delete process.env.GRIST_DEFAULT_PRODUCT;
+      orgId = (await home.addOrg(user!, {name: 'NewOrg', domain: 'novel-org'}, {
+        setUserAsOwner: false,
+        useNewPlan: true,
+      })).data!;
+
+      org = await home.getOrg({userId: user!.id}, orgId);
+      assert.equal(org.data!.billingAccount.product.name, STUB_PLAN);
+      await home.deleteOrg({userId: user!.id}, orgId);
+    } finally {
+      oldEnv.restore();
+    }
+  });
+
+  it('cannot duplicate a domain', async function() {
+    const user = await home.getUserByLogin('chimpy@getgrist.com');
+    const domain = 'repeated-domain';
+    const result = await home.addOrg(user!, {name: `${domain}!`, domain}, teamOptions);
+    const orgId = result.data!;
+    assert.equal(result.status, 200);
+    await assert.isRejected(home.addOrg(user!, {name: `${domain}!`, domain}, teamOptions),
+                            /Domain already in use/);
+    await home.deleteOrg({userId: user!.id}, orgId);
+  });
+
+  it('cannot add an org with a (blacklisted) dodgy domain', async function() {
+    const user = await home.getUserByLogin('chimpy@getgrist.com');
+    const userId = user!.id;
+    const misses = [
+      'thing!', ' thing', 'ww', 'docs-999', 'o-99', '_domainkey', 'www', 'api',
+      'thissubdomainiswaytoolongmyfriendyoushouldrethinkitoratleastsummarizeit',
+      'google', 'login', 'doc-worker-1-1-1-1', 'a', 'bb', 'x_y', '1ogin'
+    ];
+    const hits = [
+      'thing', 'jpl', 'xyz', 'appel', '123', '1google'
+    ];
+    for (const domain of misses) {
+      const result = await home.addOrg(user!, {name: `${domain}!`, domain}, teamOptions);
+      assert.equal(result.status, 400);
+      const org = await home.getOrg({userId}, domain);
+      assert.equal(org.status, 404);
+    }
+    for (const domain of hits) {
+      const result = await home.addOrg(user!, {name: `${domain}!`, domain}, teamOptions);
+      assert.equal(result.status, 200);
+      const org = await home.getOrg({userId}, domain);
+      assert.equal(org.status, 200);
+      await home.deleteOrg({userId}, org.data!.id);
+    }
+  });
+
+  it('should allow setting doc metadata', async function() {
+    const beforeRun = new Date();
+    const setDateISO1 = new Date(Date.UTC(1993, 3, 2)).toISOString();
+    const setDateISO2 = new Date(Date.UTC(2004, 6, 18)).toISOString();
+    const setUsage1 = {rowCount: {total: 123}, dataSizeBytes: 456, attachmentsSizeBytes: 789};
+    const setUsage2 = {rowCount: {total: 0}, attachmentsSizeBytes: 0};
+
+    // Set the doc updatedAt time on Bananas.
+    const primatelyOrgId = await home.testGetId('Primately') as number;
+    const fishOrgId = await home.testGetId('Fish') as number;
+    const applesDocId = await home.testGetId('Apples') as string;
+    const bananasDocId = await home.testGetId('Bananas') as string;
+    const sharkDocId = await home.testGetId('Shark') as string;
+    await home.setDocsMetadata({
+      [applesDocId]: {usage: setUsage1},
+      [bananasDocId]: {updatedAt: setDateISO1},
+      [sharkDocId]: {updatedAt: setDateISO2, usage: setUsage2},
+    });
+
+    // Fetch the doc and check that the updatedAt value is as expected.
+    const kiwi = await home.getUserByLogin('kiwi@getgrist.com');
+    const resp1 = await home.getOrgWorkspaces({userId: kiwi!.id}, primatelyOrgId);
+    assert.equal(resp1.status, 200);
+
+    // Check that the apples metadata is as expected. updatedAt should have been set
+    // when the db was initialized before the update run - it should not have been updated
+    // to 1993. usage should be set.
+    const apples = resp1.data![0].docs.find((doc: any) => doc.name === 'Apples');
+    const applesUpdate = new Date(apples!.updatedAt);
+    assert.isTrue(applesUpdate < beforeRun);
+    assert.isTrue(applesUpdate > new Date('2000-1-1'));
+    assert.deepEqual(apples!.usage, setUsage1);
+
+    // Check that the bananas metadata is as expected. updatedAt should have been set
+    // to 1993. usage should be null.
+    const bananas = resp1.data![0].docs.find((doc: any) => doc.name === 'Bananas');
+    assert.equal(bananas!.updatedAt.toISOString(), setDateISO1);
+    assert.equal(bananas!.usage, null);
+
+    // Check that the shark metadata is as expected. updatedAt should have been set
+    // to 2004. usage should be set.
+    const resp2 = await home.getOrgWorkspaces({userId: kiwi!.id}, fishOrgId);
+    assert.equal(resp2.status, 200);
+    const shark = resp2.data![0].docs.find((doc: any) => doc.name === 'Shark');
+    assert.equal(shark!.updatedAt.toISOString(), setDateISO2);
+    assert.deepEqual(shark!.usage, setUsage2);
+  });
+
+  it("can pool orgs for two users", async function() {
+    const charonOrgs = (await home.getOrgs([charonProfile], null)).data!;
+    const kiwiOrgs = (await home.getOrgs([kiwiProfile], null)).data!;
+    const pooledOrgs = (await home.getOrgs([charonProfile, kiwiProfile], null)).data!;
+    // test there is some overlap
+    assert.isAbove(pooledOrgs.length, charonOrgs.length);
+    assert.isAbove(pooledOrgs.length, kiwiOrgs.length);
+    assert.isBelow(pooledOrgs.length, charonOrgs.length + kiwiOrgs.length);
+    // check specific orgs returned
+    assert.sameDeepMembers(charonOrgs.map(org => org.name),
+      ['Abyss', 'Fish', 'NASA', 'Charonland', 'Chimpyland']);
+    assert.sameDeepMembers(kiwiOrgs.map(org => org.name),
+      ['Fish', 'Flightless', 'Kiwiland', 'Primately']);
+    assert.sameDeepMembers(pooledOrgs.map(org => org.name),
+      ['Abyss', 'Fish', 'Flightless', 'NASA', 'Primately', 'Charonland', 'Chimpyland', 'Kiwiland']);
+
+    // make sure if there are no profiles that we get no orgs
+    const emptyOrgs = (await home.getOrgs([], null)).data!;
+    assert.lengthOf(emptyOrgs, 0);
+  });
+
+  it("can pool orgs for three users", async function() {
+    const pooledOrgs = (await home.getOrgs([charonProfile, chimpyProfile, kiwiProfile], null)).data!;
+    assert.sameDeepMembers(pooledOrgs.map(org => org.name), [
+      'Abyss',
+      'EmptyOrg',
+      'EmptyWsOrg',
+      'Fish',
+      'Flightless',
+      'FreeTeam',
+      'NASA',
+      'Primately',
+      'TestDailyApiLimit',
+      'Charonland',
+      'Chimpyland',
+      'Kiwiland',
+    ]);
+  });
+
+  it("can pool orgs for multiple users with non-normalized emails", async function() {
+    const refOrgs = (await home.getOrgs([charonProfile, kiwiProfile], null)).data!;
+    // Profiles in sessions can have email addresses with arbitrary capitalization.
+    const oddCharonProfile = {email: 'CharON@getgrist.COM', name: 'charON'};
+    const oddKiwiProfile = {email: 'KIWI@getgrist.COM', name: 'KIwi'};
+    const orgs = (await home.getOrgs([oddCharonProfile, kiwiProfile, oddKiwiProfile], null)).data!;
+    assert.deepEqual(refOrgs, orgs);
+  });
+
+  it('can get best user for accessing org', async function() {
+    let suggestion = await home.getBestUserForOrg([charonProfile, kiwiProfile],
+                                                  await home.testGetId('Fish') as number);
+    assert.deepEqual(suggestion, {
+      id: await home.testGetId('Kiwi') as number,
+      email: kiwiProfile.email,
+      name: kiwiProfile.name,
+      access: 'editors',
+      perms: 15
+    });
+    suggestion = await home.getBestUserForOrg([charonProfile, kiwiProfile],
+                                              await home.testGetId('Abyss') as number);
+    assert.equal(suggestion!.email, charonProfile.email);
+    suggestion = await home.getBestUserForOrg([charonProfile, kiwiProfile],
+                                              await home.testGetId('EmptyOrg') as number);
+    assert.equal(suggestion, null);
+  });
+
+  it('skips picking a user for merged personal org', async function() {
+    // There isn't any particular way to favor one user over another when accessing
+    // the merged personal org.
+    assert.equal(await home.getBestUserForOrg([charonProfile, kiwiProfile], 0), null);
+  });
+
+  it('can access billingAccount for org', async function() {
+    await server.addBillingManager('Chimpy', 'nasa');
+    const chimpyScope = {userId: await home.testGetId('Chimpy') as number};
+    const charonScope = {userId: await home.testGetId('Charon') as number};
+
+    // billing account without orgs+managers
+    let billingAccount = await home.getBillingAccount(chimpyScope, 'nasa', false);
+    assert.hasAllKeys(billingAccount,
+                      ['id', 'individual', 'inGoodStanding', 'status', 'stripeCustomerId',
+                       'stripeSubscriptionId', 'stripePlanId', 'product', 'paid', 'isManager',
+                       'externalId', 'externalOptions', 'features', 'paymentLink']);
+
+    // billing account with orgs+managers
+    billingAccount = await home.getBillingAccount(chimpyScope, 'nasa', true);
+    assert.hasAllKeys(billingAccount,
+                      ['id', 'individual', 'inGoodStanding', 'status', 'stripeCustomerId',
+                       'stripeSubscriptionId', 'stripePlanId', 'product', 'orgs', 'managers', /* <-- here */
+                       'paid', 'externalId', 'externalOptions', 'features', 'paymentLink']);
+
+    await assert.isRejected(home.getBillingAccount(charonScope, 'nasa', true),
+                            /User does not have access to billing account/);
+  });
+
+  // TypeORM does not handle parameter name reuse well, so we monkey-patch to detect it.
+  it('will fail on parameter collision', async function() {
+    // Check collision in a simple query.
+    // Note: it is query construction that fails, not query execution.
+    assert.throws(() => home.connection.createQueryBuilder().from('orgs', 'orgs')
+                  .where('id = :id', {id: 1}).andWhere('id = :id', {id: 2}),
+                  /parameter collision/);
+
+    // Check collision between subqueries.
+    assert.throws(
+      () => home.connection.createQueryBuilder().from('orgs', 'orgs')
+        .select(q => q.subQuery().from('orgs', 'orgs').where('x IN :x', {x: ['five']}))
+        .addSelect(q => q.subQuery().from('orgs', 'orgs').where('x IN :x', {x: ['six']})),
+        /parameter collision/);
+  });
+
+  it('can get the product associated with a docId', async function() {
+    const urlId = 'sampledocid_6';
+    const userId = await home.testGetId('Chimpy') as number;
+    const scope = {userId, urlId};
+    const doc = await home.getDoc(scope);
+    const product = (await home.getDocProduct(urlId))!;
+    assert.equal(doc.workspace.org.billingAccount.product.id, product.id);
+    const features = await home.getDocFeatures(urlId);
+    assert.deepEqual(features, {workspaces: true, vanityDomain: true});
+  });
+
+  it('can fork docs', async function() {
+    const user1 = await home.getUserByLogin('kiwi@getgrist.com');
+    const user1Id = user1!.id;
+    const orgId = await home.testGetId('Fish') as number;
+    const doc1Id = await home.testGetId('Shark') as string;
+    const scope = {userId: user1Id, urlId: doc1Id};
+    const doc1 = await home.getDoc(scope);
+
+    // Document "Shark" should initially have no forks.
+    const resp1 = await home.getOrgWorkspaces({userId: user1Id}, orgId);
+    const resp1Doc = resp1.data![0].docs.find((d: any) => d.name === 'Shark');
+    assert.deepEqual(resp1Doc!.forks, []);
+
+    // Fork "Shark" as Kiwi and check that their fork is listed.
+    const fork1Id = `${doc1Id}_fork_1`;
+    await home.forkDoc(user1Id, doc1, fork1Id);
+    const resp2 = await home.getOrgWorkspaces({userId: user1Id}, orgId);
+    const resp2Doc = resp2.data![0].docs.find((d: any) => d.name === 'Shark');
+    assert.deepEqual(
+      resp2Doc!.forks.map((fork: any) => omit(fork, 'updatedAt')),
+      [
+        {
+          id: fork1Id,
+          trunkId: doc1Id,
+          createdBy: user1Id,
+          options: null,
+        },
+      ]
+    );
+
+    // Fork "Shark" again and check that Kiwi can see both forks.
+    const fork2Id = `${doc1Id}_fork_2`;
+    await home.forkDoc(user1Id, doc1, fork2Id);
+    const resp3 = await home.getOrgWorkspaces({userId: user1Id}, orgId);
+    const resp3Doc = resp3.data![0].docs.find((d: any) => d.name === 'Shark');
+    assert.sameDeepMembers(
+      resp3Doc!.forks.map((fork: any) => omit(fork, 'updatedAt')),
+      [
+        {
+          id: fork1Id,
+          trunkId: doc1Id,
+          createdBy: user1Id,
+          options: null,
+        },
+        {
+          id: fork2Id,
+          trunkId: doc1Id,
+          createdBy: user1Id,
+          options: null,
+        },
+      ]
+    );
+
+    // Now fork "Shark" as Chimpy, and check that Kiwi's forks aren't listed.
+    const user2 = await home.getUserByLogin('chimpy@getgrist.com');
+    const user2Id = user2!.id;
+    const resp4 = await home.getOrgWorkspaces({userId: user2Id}, orgId);
+    const resp4Doc = resp4.data![0].docs.find((d: any) => d.name === 'Shark');
+    assert.deepEqual(resp4Doc!.forks, []);
+
+    const fork3Id = `${doc1Id}_fork_3`;
+    await home.forkDoc(user2Id, doc1, fork3Id);
+    const resp5 = await home.getOrgWorkspaces({userId: user2Id}, orgId);
+    const resp5Doc = resp5.data![0].docs.find((d: any) => d.name === 'Shark');
+    assert.deepEqual(
+      resp5Doc!.forks.map((fork: any) => omit(fork, 'updatedAt')),
+      [
+        {
+          id: fork3Id,
+          trunkId: doc1Id,
+          createdBy: user2Id,
+          options: null,
+        },
+      ]
+    );
+  });
+});
diff --git a/test/gen-server/lib/Housekeeper.ts b/test/gen-server/lib/Housekeeper.ts
new file mode 100644
index 00000000..929b2c8b
--- /dev/null
+++ b/test/gen-server/lib/Housekeeper.ts
@@ -0,0 +1,207 @@
+import { TelemetryEvent, TelemetryMetadataByLevel } from 'app/common/Telemetry';
+import { Document } from 'app/gen-server/entity/Document';
+import { Workspace } from 'app/gen-server/entity/Workspace';
+import { Housekeeper } from 'app/gen-server/lib/Housekeeper';
+import { Telemetry } from 'app/server/lib/Telemetry';
+import { assert } from 'chai';
+import * as fse from 'fs-extra';
+import moment from 'moment';
+import * as sinon from 'sinon';
+import { TestServer } from 'test/gen-server/apiUtils';
+import { openClient } from 'test/server/gristClient';
+import * as testUtils from 'test/server/testUtils';
+
+describe('Housekeeper', function() {
+  testUtils.setTmpLogLevel('error');
+  this.timeout(60000);
+
+  const org: string = 'testy';
+  const sandbox = sinon.createSandbox();
+  let home: TestServer;
+  let keeper: Housekeeper;
+
+  before(async function() {
+    home = new TestServer(this);
+    await home.start(['home', 'docs']);
+    const api = await home.createHomeApi('chimpy', 'docs');
+    await api.newOrg({name: org, domain: org});
+    keeper = home.server.housekeeper;
+    await keeper.stop();
+  });
+
+  after(async function() {
+    await home.stop();
+    sandbox.restore();
+  });
+
+  async function getDoc(docId: string) {
+    const manager = home.dbManager.connection.manager;
+    return manager.findOneOrFail(Document, {where: {id: docId}});
+  }
+
+  async function getWorkspace(wsId: number) {
+    const manager = home.dbManager.connection.manager;
+    return manager.findOneOrFail(Workspace, {where: {id: wsId}});
+  }
+
+
+  function daysAgo(days: number): Date {
+    return moment().subtract(days, 'days').toDate();
+  }
+
+  async function ageDoc(docId: string, days: number) {
+    const dbDoc = await getDoc(docId);
+    dbDoc.removedAt = daysAgo(days);
+    await dbDoc.save();
+  }
+
+  async function ageWorkspace(wsId: number, days: number) {
+    const dbWorkspace = await getWorkspace(wsId);
+    dbWorkspace.removedAt = daysAgo(days);
+    await dbWorkspace.save();
+  }
+
+  async function ageFork(forkId: string, days: number) {
+    const dbFork = await getDoc(forkId);
+    dbFork.updatedAt = daysAgo(days);
+    await dbFork.save();
+  }
+
+  it('can delete old soft-deleted docs and workspaces', async function() {
+    // Make four docs in one workspace, two in another.
+    const api = await home.createHomeApi('chimpy', org);
+    const ws1 = await api.newWorkspace({name: 'ws1'}, 'current');
+    const ws2 = await api.newWorkspace({name: 'ws2'}, 'current');
+    const doc11 = await api.newDoc({name: 'doc11'}, ws1);
+    const doc12 = await api.newDoc({name: 'doc12'}, ws1);
+    const doc13 = await api.newDoc({name: 'doc13'}, ws1);
+    const doc14 = await api.newDoc({name: 'doc14'}, ws1);
+    const doc21 = await api.newDoc({name: 'doc21'}, ws2);
+    const doc22 = await api.newDoc({name: 'doc22'}, ws2);
+
+    // Soft-delete some of the docs, and one workspace.
+    await api.softDeleteDoc(doc11);
+    await api.softDeleteDoc(doc12);
+    await api.softDeleteDoc(doc13);
+    await api.softDeleteWorkspace(ws2);
+
+    // Check that nothing is deleted by housekeeper.
+    await keeper.deleteTrash();
+    await assert.isFulfilled(getDoc(doc11));
+    await assert.isFulfilled(getDoc(doc12));
+    await assert.isFulfilled(getDoc(doc13));
+    await assert.isFulfilled(getDoc(doc14));
+    await assert.isFulfilled(getDoc(doc21));
+    await assert.isFulfilled(getDoc(doc22));
+    await assert.isFulfilled(getWorkspace(ws1));
+    await assert.isFulfilled(getWorkspace(ws2));
+
+    // Age a doc and workspace somewhat, but not enough to trigger hard-deletion.
+    await ageDoc(doc11, 10);
+    await ageWorkspace(ws2, 20);
+    await keeper.deleteTrash();
+    await assert.isFulfilled(getDoc(doc11));
+    await assert.isFulfilled(getWorkspace(ws2));
+
+    // Prematurely age two of the soft-deleted docs, and the soft-deleted workspace.
+    await ageDoc(doc11, 40);
+    await ageDoc(doc12, 40);
+    await ageWorkspace(ws2, 40);
+
+    // Make sure that exactly those docs are deleted by housekeeper.
+    await keeper.deleteTrash();
+    await assert.isRejected(getDoc(doc11));
+    await assert.isRejected(getDoc(doc12));
+    await assert.isFulfilled(getDoc(doc13));
+    await assert.isFulfilled(getDoc(doc14));
+    await assert.isRejected(getDoc(doc21));
+    await assert.isRejected(getDoc(doc22));
+    await assert.isFulfilled(getWorkspace(ws1));
+    await assert.isRejected(getWorkspace(ws2));
+  });
+
+  it('enforces exclusivity of housekeeping', async function() {
+    const first = keeper.deleteTrashExclusively();
+    const second = keeper.deleteTrashExclusively();
+    assert.equal(await first, true);
+    assert.equal(await second, false);
+    assert.equal(await keeper.deleteTrashExclusively(), false);
+    await keeper.testClearExclusivity();
+    assert.equal(await keeper.deleteTrashExclusively(), true);
+  });
+
+  it('can delete old forks', async function() {
+    // Make a document with some forks.
+    const api = await home.createHomeApi('chimpy', org);
+    const ws3 = await api.newWorkspace({name: 'ws3'}, 'current');
+    const trunk = await api.newDoc({name: 'trunk'}, ws3);
+    const session = await api.getSessionActive();
+    const client = await openClient(home.server, session.user.email, session.org?.domain || 'docs');
+    await client.openDocOnConnect(trunk);
+    const forkResponse1 = await client.send('fork', 0);
+    const forkResponse2 = await client.send('fork', 0);
+    const forkPath1 = home.server.getStorageManager().getPath(forkResponse1.data.docId);
+    const forkPath2 = home.server.getStorageManager().getPath(forkResponse2.data.docId);
+    const forkId1 = forkResponse1.data.forkId;
+    const forkId2 = forkResponse2.data.forkId;
+
+    // Age the forks somewhat, but not enough to trigger hard-deletion.
+    await ageFork(forkId1, 10);
+    await ageFork(forkId2, 20);
+    await keeper.deleteTrash();
+    await assert.isFulfilled(getDoc(forkId1));
+    await assert.isFulfilled(getDoc(forkId2));
+    assert.equal(await fse.pathExists(forkPath1), true);
+    assert.equal(await fse.pathExists(forkPath2), true);
+
+    // Age one of the forks beyond the cleanup threshold.
+    await ageFork(forkId2, 40);
+
+    // Make sure that only that fork is deleted by housekeeper.
+    await keeper.deleteTrash();
+    await assert.isFulfilled(getDoc(forkId1));
+    await assert.isRejected(getDoc(forkId2));
+    assert.equal(await fse.pathExists(forkPath1), true);
+    assert.equal(await fse.pathExists(forkPath2), false);
+  });
+
+  it('can log metrics about sites', async function() {
+    const logMessages: [TelemetryEvent, TelemetryMetadataByLevel?][] = [];
+    sandbox.stub(Telemetry.prototype, 'shouldLogEvent').callsFake((name) => true);
+    sandbox.stub(Telemetry.prototype, 'logEvent').callsFake((_, name, meta) => {
+      // Skip document usage events that could be arriving in the
+      // middle of this test.
+      if (name !== 'documentUsage') {
+        logMessages.push([name, meta]);
+      }
+      return Promise.resolve();
+    });
+    await keeper.logMetrics();
+    assert.isNotEmpty(logMessages);
+    let [event, meta] = logMessages[0];
+    assert.equal(event, 'siteUsage');
+    assert.hasAllKeys(meta?.limited, [
+      'siteId',
+      'siteType',
+      'inGoodStanding',
+      'numDocs',
+      'numWorkspaces',
+      'numMembers',
+      'lastActivity',
+      'earliestDocCreatedAt',
+    ]);
+    assert.hasAllKeys(meta?.full, [
+      'stripePlanId',
+    ]);
+    [event, meta] = logMessages[logMessages.length - 1];
+    assert.equal(event, 'siteMembership');
+    assert.hasAllKeys(meta?.limited, [
+      'siteId',
+      'siteType',
+      'numOwners',
+      'numEditors',
+      'numViewers',
+    ]);
+    assert.isUndefined(meta?.full);
+  });
+});
diff --git a/test/gen-server/lib/emails.ts b/test/gen-server/lib/emails.ts
new file mode 100644
index 00000000..aae3957c
--- /dev/null
+++ b/test/gen-server/lib/emails.ts
@@ -0,0 +1,160 @@
+import {PermissionData, PermissionDelta} from 'app/common/UserAPI';
+import axios from 'axios';
+import {assert} from 'chai';
+import {TestServer} from 'test/gen-server/apiUtils';
+import {configForUser} from 'test/gen-server/testUtils';
+import * as testUtils from 'test/server/testUtils';
+
+describe('emails', function() {
+
+  let server: TestServer;
+  let serverUrl: string;
+  testUtils.setTmpLogLevel('error');
+
+  const regular = 'chimpy@getgrist.com';
+  const variant = 'Chimpy@GETgrist.com';
+  const apiKey = configForUser('Chimpy');
+  let ref: (email: string) => Promise<string>;
+
+  beforeEach(async function() {
+    this.timeout(5000);
+    server = new TestServer(this);
+    ref = (email: string) => server.dbManager.getUserByLogin(email).then((user) => user!.ref);
+    serverUrl = await server.start();
+  });
+
+  afterEach(async function() {
+    await server.stop();
+  });
+
+  it('email capitalization from provider is sticky', async function() {
+    let cookie = await server.getCookieLogin('nasa', {email: regular, name: 'Chimpy'});
+    const userRef = await ref(regular);
+    // profile starts off with chimpy@ email
+    let resp = await axios.get(`${serverUrl}/o/nasa/api/profile/user`, cookie);
+    assert.equal(resp.status, 200);
+    assert.deepEqual(resp.data, {
+      id: 1, email: regular, name: "Chimpy", ref: userRef, picture: null, allowGoogleLogin: true
+    });
+
+    // now we log in with simulated provider giving a Chimpy@ capitalization.
+    cookie = await server.getCookieLogin('nasa', {email: variant, name: 'Chimpy'});
+    resp = await axios.get(`${serverUrl}/o/nasa/api/profile/user`, cookie);
+    assert.equal(resp.status, 200);
+    // Chimpy@ is now what we see in our profile, but our id is still the same.
+    assert.deepEqual(resp.data, {
+      id: 1, email: variant, loginEmail: regular, name: "Chimpy", ref: userRef, picture: null, allowGoogleLogin: true
+    });
+
+    // read our profile with api key (no session involved) and make sure result is the same.
+    resp = await axios.get(`${serverUrl}/api/profile/user`, apiKey);
+    assert.equal(resp.status, 200);
+    assert.deepEqual(resp.data, {
+      id: 1, email: variant, loginEmail: regular, name: "Chimpy", ref: userRef, picture: null, allowGoogleLogin: true
+    });
+
+  });
+
+  it('access endpoints show and accept display emails', async function() {
+    // emails are used in access endpoints - make sure they provide the display email.
+
+    const resources = [
+      { type: 'orgs', id: await server.dbManager.testGetId('NASA') },
+      { type: 'workspaces', id: await server.dbManager.testGetId('Horizon') },
+      { type: 'docs', id: await server.dbManager.testGetId('Jupiter') },
+    ] as const;
+
+    for (const res of resources) {
+      // initially, should report regular chimpy address
+      const resp = await axios.get(`${serverUrl}/api/${res.type}/${res.id}/access`, apiKey);
+      assert.equal(resp.status, 200);
+      const delta: PermissionData = resp.data;
+      assert.notInclude(delta.users.map(u => u.email), variant);
+      assert.include(delta.users.map(u => u.email), regular);
+    }
+
+    const cookie = await server.getCookieLogin('nasa', {email: variant, name: 'Chimpy'});
+    await axios.get(`${serverUrl}/o/nasa/api/orgs`, cookie);
+
+    for (const res of resources) {
+      // now, should report variant chimpy address
+      let resp = await axios.get(`${serverUrl}/api/${res.type}/${res.id}/access`, apiKey);
+      assert.equal(resp.status, 200);
+      const delta: PermissionData = resp.data;
+      assert.include(delta.users.map(u => u.email), variant);
+      assert.notInclude(delta.users.map(u => u.email), regular);
+
+      // and make sure arbitrary capitalization is accepted and effective.
+      const delta2: {delta: PermissionDelta} = {
+        delta: {
+          users: {
+            'chImPy@getGRIst.com': 'viewers'
+          }
+        }
+      };
+      resp = await axios.patch(`${serverUrl}/api/${res.type}/${res.id}/access`, delta2, apiKey);
+      // expect an error complaining about not being able to change own permissions.
+      assert.match(resp.data.error, /own permissions/);
+    }
+  });
+
+  it('PATCH access endpoints behave reasonably when multiple versions of email given', async function() {
+    const orgId = await server.dbManager.testGetId('NASA');
+
+    let resp = await axios.get(`${serverUrl}/api/orgs/${orgId}/access`, apiKey);
+    assert.deepEqual(resp.data, { users: [
+      {
+        id: 1,
+        name: 'Chimpy',
+        email: 'chimpy@getgrist.com',
+        ref: await ref('chimpy@getgrist.com'),
+        picture: null,
+        access: 'owners',
+        isMember: true,
+      },
+      {
+        id: 3,
+        name: 'Charon',
+        email: 'charon@getgrist.com',
+        ref: await ref('charon@getgrist.com'),
+        picture: null,
+        access: 'guests',
+        isMember: false,
+      },
+    ]});
+
+    const delta: {delta: PermissionDelta} = {
+      delta: {
+        users: {
+          'kiWI@getGRIst.com': 'viewers',
+          'KIwi@getgrist.com': 'editors',
+          'charON@getgrist.com': null,
+        }
+      }
+    };
+    resp = await axios.patch(`${serverUrl}/api/orgs/${orgId}/access`, delta, apiKey);
+    assert.equal(resp.status, 200);
+
+    resp = await axios.get(`${serverUrl}/api/orgs/${orgId}/access`, apiKey);
+    assert.deepEqual(resp.data, { users: [
+      {
+        id: 1,
+        name: 'Chimpy',
+        email: 'chimpy@getgrist.com',
+        ref: await ref('chimpy@getgrist.com'),
+        picture: null,
+        access: 'owners',
+        isMember: true,
+      },
+      {
+        id: 2,
+        name: 'Kiwi',
+        email: 'kiwi@getgrist.com',
+        ref: await ref('kiwi@getgrist.com'),
+        picture: null,
+        access: 'editors',
+        isMember: true,
+      },
+    ]});
+  });
+});
diff --git a/test/gen-server/lib/everyone.ts b/test/gen-server/lib/everyone.ts
new file mode 100644
index 00000000..d42635b1
--- /dev/null
+++ b/test/gen-server/lib/everyone.ts
@@ -0,0 +1,179 @@
+import {Workspace} from 'app/common/UserAPI';
+import {assert} from 'chai';
+import {TestServer} from 'test/gen-server/apiUtils';
+import * as testUtils from 'test/server/testUtils';
+
+describe('everyone', function() {
+  let home: TestServer;
+  testUtils.setTmpLogLevel('error');
+
+  before(async function() {
+    home = new TestServer(this);
+    await home.start();
+  });
+
+  after(async function() {
+    await home.stop();
+  });
+
+  /**
+   * Assert that the specified workspaces and their material are public,
+   * and that all other workspaces are not.
+   */
+  async function assertPublic(wss: Workspace[], publicWorkspaces: string[]) {
+    for (const ws of wss) {
+      const expectedPublic = publicWorkspaces.includes(ws.name) || undefined;
+      assert.equal(ws.public, expectedPublic);
+      for (const doc of ws.docs) {
+        assert.equal(doc.public, expectedPublic);
+      }
+    }
+  }
+
+  it('support account can share a listed workspace with all users', async function() {
+
+    // Share a workspace in support's personal org with everyone
+    let api = await home.createHomeApi('Support', 'docs');
+    await home.upgradePersonalOrg('Support');
+    const wsId = await api.newWorkspace({name: 'Samples'}, 'current');
+    const docId = await api.newDoc({name: 'an example'}, wsId);
+    await api.updateWorkspacePermissions(wsId, {
+      users: {'everyone@getgrist.com': 'viewers',
+              'anon@getgrist.com': 'viewers'}
+    });
+
+    // Check a fresh user can see that workspace
+    const altApi = await home.createHomeApi('testuser', 'docs');
+    let wss = await altApi.getOrgWorkspaces('current');
+    assert.deepEqual(wss.map(ws => ws.name), ['Home', 'Samples']);
+    assert.deepEqual(wss[1].docs.map(doc => doc.id), [docId]);
+
+    // Check that public flag is set in everything the fresh user can see outside its Home.
+    await assertPublic(wss, ['Samples']);
+
+    // Check existing users can see that workspace
+    const chimpyApi = await home.createHomeApi('Chimpy', 'docs');
+    wss = await chimpyApi.getOrgWorkspaces('current');
+    assert.deepEqual(wss.map(ws => ws.name), ['Private', 'Public', 'Samples']);
+    assert.deepEqual(wss.map(ws => ws.isSupportWorkspace), [false, false, true]);
+    // Public and Private could be in either order, but Samples should be last
+    // (api returns workspaces in chronological order).
+    assert.equal(wss[2].name, 'Samples');
+    assert.deepEqual(wss[2].docs.map(doc => doc.id), [docId]);
+    await assertPublic(wss, ['Samples']);
+
+    // Check that workspace also shows up in regular orgs
+    const nasaApi = await home.createHomeApi('Chimpy', 'nasa');
+    wss = await nasaApi.getOrgWorkspaces('current');
+    assert.deepEqual(wss.map(ws => ws.name), ['Horizon', 'Rovers', 'Samples']);
+    assert.deepEqual(wss.map(ws => ws.isSupportWorkspace), [false, false, true]);
+    await assertPublic(wss, ['Samples']);
+
+    // Need to recreate api because of cookies
+    api = await home.createHomeApi('Support', 'docs');
+    await api.deleteWorkspace(wsId);
+  });
+
+  it('can share unlisted docs in personal org with all users', async function() {
+    const api = await home.createHomeApi('Supportish', 'docs');
+    await home.upgradePersonalOrg('Supportish');
+    const wsId = await api.newWorkspace({name: 'Samples2'}, 'current');
+    const docId = await api.newDoc({name: 'an example'}, wsId);
+    // Check other users cannot access the doc yet
+    const chimpyApi = await home.createHomeApi('Chimpy', 'docs', true);
+    await assert.isRejected(chimpyApi.getDoc(docId), /access denied/);
+    // Share doc with everyone
+    await api.updateDocPermissions(docId, {
+      users: {'everyone@getgrist.com': 'viewers'}
+    });
+    // Check other users can access the doc now
+    assert.equal((await chimpyApi.getDoc(docId)).access, 'viewers');
+    // Check that doc is marked as public
+    assert.equal((await chimpyApi.getDoc(docId)).public, true);
+    // Check they don't see doc listed
+    let wss = await chimpyApi.getOrgWorkspaces('current');
+    assert.deepEqual(wss.map(ws => ws.name), ['Private', 'Public']);
+
+    // Share every way possible via api
+    await api.updateWorkspacePermissions(wsId, {
+      users: {'everyone@getgrist.com': 'viewers'}
+    });
+    await assert.isRejected(api.updateOrgPermissions(0, {
+      users: {'everyone@getgrist.com': 'viewers'}
+    }), /cannot share with everyone at top level/);
+    // Check existing users still don't see doc listed
+    wss = await chimpyApi.getOrgWorkspaces('current');
+    assert.deepEqual(wss.map(ws => ws.name), ['Private', 'Public']);
+  });
+
+  it('can share unlisted docs in team sites with all users', async function() {
+    const chimpyApi = await home.createHomeApi('Chimpy', 'nasa', true);
+    const wsId = await chimpyApi.newWorkspace({name: 'Samples'}, 'current');
+    const docId = await chimpyApi.newDoc({name: 'an example'}, wsId);
+
+    // Check a fresh user cannot see that doc
+    const altApi = await home.createHomeApi('testuser', 'nasa', false, false);
+    await assert.isRejected(altApi.getDoc(docId), /access denied/i);
+
+    // Share doc with everyone
+    await chimpyApi.updateDocPermissions(docId, {
+      users: {'everyone@getgrist.com': 'viewers'}
+    });
+
+    // Check a fresh user can now see that doc
+    await assert.isFulfilled(altApi.getDoc(docId));
+
+    // Check that doc is marked as public
+    assert.equal((await altApi.getDoc(docId)).public, true);
+
+    // But can't list that doc in team site
+    await assert.isRejected(altApi.getOrgWorkspaces('current'), /access denied/);
+
+    // Also can't list the doc in workspace
+    await assert.isRejected(altApi.getWorkspace(wsId), /access denied/);
+  });
+
+  it('can share public docs without them being listed indirectly', async function() {
+    const chimpyApi = await home.createHomeApi('Chimpy', 'nasa', true);
+    const wsId = await chimpyApi.newWorkspace({name: 'Samples'}, 'current');
+    const docId = await chimpyApi.newDoc({name: 'an example'}, wsId);
+    const docId2 = await chimpyApi.newDoc({name: 'another example'}, wsId);
+
+    // Share one doc with everyone
+    await chimpyApi.updateDocPermissions(docId, {
+      users: {'everyone@getgrist.com': 'viewers'}
+    });
+
+    // Share one doc with everyone, the other with a specific test user at the doc level
+    const altApi = await home.createHomeApi('testuser', 'nasa', false, false);
+    await chimpyApi.updateDocPermissions(docId, {
+      users: {'everyone@getgrist.com': 'viewers'}
+    });
+    await chimpyApi.updateDocPermissions(docId2, {
+      users: {'testuser@getgrist.com': 'viewers'}
+    });
+
+    // Check test user can access both docs
+    await assert.isFulfilled(altApi.getDoc(docId));
+    await assert.isFulfilled(altApi.getDoc(docId2));
+
+    // Check test user can only list the documents shared with them
+    // through a route other than public sharing
+    assert.deepEqual((await altApi.getOrgWorkspaces('current'))[0].docs.map(doc => doc.name),
+                     ['another example']);
+    assert.deepEqual((await altApi.getWorkspace(wsId)).docs.map(doc => doc.name),
+                     ['another example']);
+
+    // Check that a viewer at org level can see all docs listed, and access them
+    // (there was a bug where a doc shared with everyone@ as viewer would get hidden
+    // from top-level viewers)
+    await chimpyApi.updateOrgPermissions('current', {
+      users: {'testuser2@getgrist.com': 'viewers'}
+    });
+    const altApi2 = await home.createHomeApi('testuser2', 'nasa', false, false);
+    await assert.isFulfilled(altApi2.getDoc(docId));
+    await assert.isFulfilled(altApi2.getDoc(docId2));
+    assert.sameMembers((await altApi2.getWorkspace(wsId)).docs.map(doc => doc.name),
+                       ['an example', 'another example']);
+  });
+});
diff --git a/test/gen-server/lib/limits.ts b/test/gen-server/lib/limits.ts
new file mode 100644
index 00000000..669915f1
--- /dev/null
+++ b/test/gen-server/lib/limits.ts
@@ -0,0 +1,553 @@
+import {ApiError} from 'app/common/ApiError';
+import {Features} from 'app/common/Features';
+import {resetOrg} from 'app/common/resetOrg';
+import {UserAPI, UserAPIImpl} from 'app/common/UserAPI';
+import {BillingAccount} from 'app/gen-server/entity/BillingAccount';
+import {Organization} from 'app/gen-server/entity/Organization';
+import {Product} from 'app/gen-server/entity/Product';
+import {HomeDBManager} from 'app/gen-server/lib/homedb/HomeDBManager';
+import {GristObjCode} from 'app/plugin/GristData';
+import {assert} from 'chai';
+import { IOptions } from 'app/common/BaseAPI';
+import FormData from 'form-data';
+import fetch from 'node-fetch';
+import {TestServer} from 'test/gen-server/apiUtils';
+import {configForUser, createUser} from 'test/gen-server/testUtils';
+import * as testUtils from 'test/server/testUtils';
+
+describe('limits', function() {
+  let home: TestServer;
+  let dbManager: HomeDBManager;
+  let homeUrl: string;
+  let product: Product;
+  let api: UserAPI;
+  let nasa: UserAPI;
+
+  testUtils.setTmpLogLevel('error');
+
+  before(async function() {
+    home = new TestServer(this);
+    await home.start(["home", "docs"]);
+
+    dbManager = home.dbManager;
+    homeUrl = home.serverUrl;
+
+    // Create a test product
+    product = new Product();
+    product.name = "test_product";
+    product.features = {workspaces: true};
+    await product.save();
+    // Create a new user
+    const samHome = await createUser(dbManager, 'sam');
+    // Overwrite default product
+    const billingId = samHome.billingAccount.id;
+    await dbManager.connection.createQueryBuilder()
+      .update(BillingAccount)
+      .set({product})
+      .where('id = :billingId', {billingId})
+      .execute();
+    // Set up an api object tied to the user's personal org
+    api = new UserAPIImpl(`${homeUrl}/o/docs`, {
+      fetch: fetch as any,
+      newFormData: () => new FormData() as any,
+      ...configForUser('sam') as IOptions
+    });
+    // Give chimpy access to this org
+    await api.updateOrgPermissions('current', {users: {'chimpy@getgrist.com': 'owners'}});
+    // Set up an api object tied to nasa
+    nasa = new UserAPIImpl(`${homeUrl}/o/nasa`, {
+      fetch: fetch as any,
+      ...configForUser('chimpy') as IOptions
+    });
+  });
+
+  after(async function() {
+    await home.stop();
+  });
+
+  async function setFeatures(features: Features) {
+    product.features = features;
+    await product.save();
+  }
+
+  it('can enforce limits on number of workspaces', async function() {
+    await setFeatures({maxWorkspacesPerOrg: 2, workspaces: true});
+
+    // initially have just one workspace, the default workspace
+    // created for a new personal org.
+    assert.lengthOf(await api.getOrgWorkspaces('current'), 1);
+    await assert.isFulfilled(api.newWorkspace({name: 'work2'}, 'current'));
+    await assert.isRejected(api.newWorkspace({name: 'work3'}, 'current'),
+                            /No more workspaces/);
+
+    await setFeatures({maxWorkspacesPerOrg: 3, workspaces: true});
+    await assert.isFulfilled(api.newWorkspace({name: 'work3'}, 'current'));
+    await assert.isRejected(api.newWorkspace({name: 'work4'}, 'current'),
+                            /No more workspaces/);
+
+    await setFeatures({workspaces: true});
+    await assert.isFulfilled(api.newWorkspace({name: 'work4'}, 'current'));
+
+    await setFeatures({maxWorkspacesPerOrg: 1, workspaces: true});
+    await assert.isRejected(api.newWorkspace({name: 'work5'}, 'current'),
+                            /No more workspaces/);
+  });
+
+  it('can enforce limits on number of workspace shares', async function() {
+    this.timeout(4000);
+    await setFeatures({maxSharesPerWorkspace: 3, workspaces: true});
+    const wsId = await api.newWorkspace({name: 'work'}, 'docs');
+
+    // Adding 4 users would exceed 3 user limit
+    await assert.isRejected(api.updateWorkspacePermissions(wsId, {
+      users: {
+        'user1@getgrist.com': 'owners',
+        'user2@getgrist.com': 'viewers',
+        'user3@getgrist.com': 'owners',
+        'user4@getgrist.com': 'viewers',
+      }
+    }), /No more external workspace shares/);
+
+    // Adding 1 user is ok
+    await assert.isFulfilled(api.updateWorkspacePermissions(wsId, {
+      users: {'user1@getgrist.com': 'owners'}
+    }));
+
+    // Adding 2nd+3rd user is ok
+    await assert.isFulfilled(api.updateWorkspacePermissions(wsId, {
+      users: {
+        'user2@getgrist.com': 'owners',
+        'user3@getgrist.com': 'owners'
+      }
+    }));
+
+    // Adding 4th user fails
+    await assert.isRejected(api.updateWorkspacePermissions(wsId, {
+      users: {'user4@getgrist.com': 'owners'}
+    }), /No more external workspace shares/);
+
+    // Adding support user is ok
+    await assert.isFulfilled(api.updateWorkspacePermissions(wsId, {
+      users: {'support@getgrist.com': 'owners'}
+    }));
+
+    // Replacing user is ok
+    await assert.isFulfilled(api.updateWorkspacePermissions(wsId, {
+      users: {
+        'user2@getgrist.com': null,
+        'user2b@getgrist.com': 'owners'
+      }
+    }));
+
+    // Removing a user and adding another is ok
+    await assert.isFulfilled(api.updateWorkspacePermissions(wsId, {
+      users: {'user1@getgrist.com': null}
+    }));
+    await assert.isFulfilled(api.updateWorkspacePermissions(wsId, {
+      users: {'user1b@getgrist.com': 'owners'}
+    }));
+    await assert.isRejected(api.updateWorkspacePermissions(wsId, {
+      users: {'user5@getgrist.com': 'owners'}
+    }), /No more external workspace shares/);
+
+    // Reduce to limit to allow just one share
+    await setFeatures({maxSharesPerWorkspace: 1, workspaces: true});
+
+    // Cannot add or replace users, since we are over limit
+    await assert.isRejected(api.updateWorkspacePermissions(wsId, {
+      users: {
+        'user3@getgrist.com': null,
+        'user3b@getgrist.com': 'owners'
+      }
+    }), /No more external workspace shares/);
+
+    // Can remove a user, while still being over limit
+    await assert.isFulfilled(api.updateWorkspacePermissions(wsId, {
+      users: {'user1b@getgrist.com': null}
+    }));
+    await assert.isFulfilled(api.updateWorkspacePermissions(wsId, {
+      users: {'user2b@getgrist.com': null}
+    }));
+    await assert.isFulfilled(api.updateWorkspacePermissions(wsId, {
+      users: {'user3@getgrist.com': null}
+    }));
+
+    // Finally ok to add a user again
+    await assert.isFulfilled(api.updateWorkspacePermissions(wsId, {
+      users: {'user1@getgrist.com': 'owners'}
+    }));
+  });
+
+  it('can enforce limits on number of docs', async function() {
+    await setFeatures({maxDocsPerOrg: 2, workspaces: true});
+    const wsId = await api.newWorkspace({name: 'work'}, 'docs');
+
+    await assert.isFulfilled(api.newDoc({name: 'doc1'}, wsId));
+    await assert.isFulfilled(api.newDoc({name: 'doc2'}, wsId));
+    await assert.isRejected(api.newDoc({name: 'doc3'}, wsId), /No more documents/);
+
+    await setFeatures({maxDocsPerOrg: 3, workspaces: true});
+    await assert.isFulfilled(api.newDoc({name: 'doc3'}, wsId));
+    await assert.isRejected(api.newDoc({name: 'doc4'}, wsId), /No more documents/);
+
+    await setFeatures({workspaces: true});
+    await assert.isFulfilled(api.newDoc({name: 'doc4'}, wsId));
+
+    await setFeatures({maxDocsPerOrg: 1, workspaces: true});
+    await assert.isRejected(api.newDoc({name: 'doc5'}, wsId), /No more documents/);
+
+    // check that smuggling in a document from another org doesn't work.
+    await assert.isRejected(nasa.moveDoc(await dbManager.testGetId('Jupiter') as string, wsId),
+                            /No more documents/);
+
+    // now make space for the document and try again.
+    await setFeatures({maxDocsPerOrg: 6, workspaces: true});
+    await assert.isFulfilled(nasa.moveDoc(await dbManager.testGetId('Jupiter') as string, wsId));
+
+    // add a document in a workspace we are then going to make inaccessible.
+    const wsHiddenId = await api.newWorkspace({name: 'hidden'}, 'docs');
+    await assert.isFulfilled(api.newDoc({name: 'doc6'}, wsHiddenId));
+    await assert.isRejected(api.newDoc({name: 'doc7'}, wsHiddenId), /No more documents/);
+
+    // transfer workspace ownership, and make inaccessible.
+    await api.updateWorkspacePermissions(wsHiddenId, {users: {'charon@getgrist.com': 'owners'}});
+    const charon = await home.createHomeApi('charon', 'docs', true);
+    await charon.updateWorkspacePermissions(wsHiddenId, {maxInheritedRole: null});
+
+    // now try adding a document and make sure it is denied.
+    await assert.isRejected(api.newDoc({name: 'doc7'}, wsId), /No more documents/);
+
+    // clean up workspace.
+    await charon.deleteWorkspace(wsHiddenId);
+  });
+
+  it('can enforce limits on number of doc shares', async function() {
+    this.timeout(4000);      // This can exceed the default of 2s on Jenkins
+
+    await setFeatures({maxSharesPerDoc: 3, workspaces: true});
+    const wsId = await api.newWorkspace({name: 'shares'}, 'docs');
+    const docId = await api.newDoc({name: 'doc'}, wsId);
+
+    // Adding 4 users would exceed 3 user limit
+    await assert.isRejected(api.updateDocPermissions(docId, {
+      users: {
+        'user1@getgrist.com': 'owners',
+        'user2@getgrist.com': 'viewers',
+        'user3@getgrist.com': 'owners',
+        'user4@getgrist.com': 'viewers',
+      }
+    }), /No more external document shares/);
+
+    // Adding 1 user is ok
+    await assert.isFulfilled(api.updateDocPermissions(docId, {
+      users: {'user1@getgrist.com': 'owners'}
+    }));
+
+    // Adding 2nd+3rd user is ok
+    await assert.isFulfilled(api.updateDocPermissions(docId, {
+      users: {
+        'user2@getgrist.com': 'owners',
+        'user3@getgrist.com': 'owners'
+      }
+    }));
+
+    // Adding 4th user fails
+    await assert.isRejected(api.updateDocPermissions(docId, {
+      users: {'user4@getgrist.com': 'owners'}
+    }), /No more external document shares/);
+
+    // Adding support user is ok
+    await assert.isFulfilled(api.updateDocPermissions(docId, {
+      users: {'support@getgrist.com': 'owners'}
+    }));
+
+    // Replacing user is ok
+    await assert.isFulfilled(api.updateDocPermissions(docId, {
+      users: {
+        'user2@getgrist.com': null,
+        'user2b@getgrist.com': 'owners'
+      }
+    }));
+
+    // Removing a user and adding another is ok
+    await assert.isFulfilled(api.updateDocPermissions(docId, {
+      users: {'user1@getgrist.com': null}
+    }));
+    await assert.isFulfilled(api.updateDocPermissions(docId, {
+      users: {'user1b@getgrist.com': 'owners'}
+    }));
+    await assert.isRejected(api.updateDocPermissions(docId, {
+      users: {'user5@getgrist.com': 'owners'}
+    }), /No more external document shares/);
+
+    // Reduce to limit to allow just one share
+    await setFeatures({maxSharesPerDoc: 1, workspaces: true});
+
+    // Cannot add or replace users, since we are over limit
+    await assert.isRejected(api.updateDocPermissions(docId, {
+      users: {
+        'user3@getgrist.com': null,
+        'user3b@getgrist.com': 'owners'
+      }
+    }), /No more external document shares/);
+
+    // Can remove a user, while still being over limit
+    await assert.isFulfilled(api.updateDocPermissions(docId, {
+      users: {'user1b@getgrist.com': null}
+    }));
+    await assert.isFulfilled(api.updateDocPermissions(docId, {
+      users: {'user2b@getgrist.com': null}
+    }));
+    await assert.isFulfilled(api.updateDocPermissions(docId, {
+      users: {'user3@getgrist.com': null}
+    }));
+
+    // Finally ok to add a user again
+    await assert.isFulfilled(api.updateDocPermissions(docId, {
+      users: {'user1@getgrist.com': 'owners'}
+    }));
+
+    // Try smuggling in a doc that breaks the rules
+    // Tweak NASA's product to allow 4 shares per doc.
+    const db = dbManager.connection.manager;
+    const nasaOrg = await db.findOne(Organization, {where: {domain: 'nasa'},
+                                                    relations: ['billingAccount',
+                                                                'billingAccount.product']});
+    if (!nasaOrg) { throw new Error('could not find nasa org'); }
+    const nasaProduct = nasaOrg.billingAccount.product;
+    const originalFeatures = nasaProduct.features;
+
+    nasaProduct.features = {...originalFeatures, maxSharesPerDoc: 4};
+    await nasaProduct.save();
+
+    const pluto = await dbManager.testGetId('Pluto') as string;
+    await nasa.updateDocPermissions(pluto, {
+      users: {
+        'zig@getgrist.com': 'owners',
+        'zag@getgrist.com': 'editors',
+        'zog@getgrist.com': 'viewers',
+      }
+    });
+    await assert.isRejected(nasa.moveDoc(pluto, wsId), /Too many external document shares/);
+
+    // Increase the limit and try again
+    await setFeatures({maxSharesPerDoc: 100, workspaces: true});
+    await assert.isFulfilled(nasa.moveDoc(pluto, wsId));
+  });
+
+  it('can enforce limits on number of doc shares per role', async function() {
+    this.timeout(4000);      // This can exceed the default of 2s on Jenkins
+
+    await setFeatures({maxSharesPerDoc: 10,
+                       maxSharesPerDocPerRole: {
+                         owners: 1,
+                         editors: 2
+                       },
+                       workspaces: true});
+    const wsId = await api.newWorkspace({name: 'roleShares'}, 'docs');
+    const docId = await api.newDoc({name: 'doc'}, wsId);
+
+    // can add plenty of viewers
+    await assert.isFulfilled(api.updateDocPermissions(docId, {
+      users: {
+        'viewer1@getgrist.com': 'viewers',
+        'viewer2@getgrist.com': 'viewers',
+        'viewer3@getgrist.com': 'viewers',
+        'viewer4@getgrist.com': 'viewers'
+      }
+    }));
+
+    // can add just one owner
+    await assert.isFulfilled(api.updateDocPermissions(docId, {
+      users: {'owner1@getgrist.com': 'owners'}
+    }));
+    await assert.isRejected(api.updateDocPermissions(docId, {
+      users: {'owner2@getgrist.com': 'owners'}
+    }), /No more external document owners/);
+
+    // can add at most two editors
+    await assert.isRejected(api.updateDocPermissions(docId, {
+      users: {
+        'editor1@getgrist.com': 'editors',
+        'editor2@getgrist.com': 'editors',
+        'editor3@getgrist.com': 'editors'
+      }
+    }), /No more external document editors/);
+    await assert.isFulfilled(api.updateDocPermissions(docId, {
+      users: {
+        'editor1@getgrist.com': 'editors',
+        'editor2@getgrist.com': 'editors'
+      }
+    }));
+
+    // can convert an editor to a viewer and then add another editor
+    await assert.isFulfilled(api.updateDocPermissions(docId, {
+      users: {
+        'editor1@getgrist.com': 'viewers',
+        'editor3@getgrist.com': 'editors'
+      }
+    }));
+
+    // we are at 8 shares, can make just two more
+    await assert.isFulfilled(api.updateDocPermissions(docId, {
+      users: {'viewer5@getgrist.com': 'viewers'}
+    }));
+    await assert.isFulfilled(api.updateDocPermissions(docId, {
+      users: {'viewer6@getgrist.com': 'viewers'}
+    }));
+    await assert.isRejected(api.updateDocPermissions(docId, {
+      users: {'viewer7@getgrist.com': 'viewers'}
+    }), /No more external document shares/);
+
+
+    // Try smuggling in a doc that exceeds limits
+    const beyond = await dbManager.testGetId('Beyond') as string;
+    await nasa.updateDocPermissions(beyond, {
+      users: {
+        'zig@getgrist.com': 'owners',
+        'zag@getgrist.com': 'owners'
+      }
+    });
+    await assert.isRejected(nasa.moveDoc(beyond, wsId), /Too many external document owners/);
+
+    // Increase the limit and try again
+    await setFeatures({maxSharesPerDoc: 10,
+                       maxSharesPerDocPerRole: {
+                         owners: 2,
+                         editors: 2
+                       },
+                       workspaces: true});
+    await assert.isFulfilled(nasa.moveDoc(beyond, wsId));
+  });
+
+  it('can give good tips when exceeding doc shares', async function() {
+    await setFeatures({maxSharesPerDoc: 2, workspaces: true});
+    const wsId = await api.newWorkspace({name: 'shares'}, 'docs');
+    const docId = await api.newDoc({name: 'doc'}, wsId);
+
+    await assert.isFulfilled(api.updateDocPermissions(docId, {
+      users: {
+        'user1@getgrist.com': 'owners',
+        'user2@getgrist.com': 'viewers',
+      }
+    }));
+    let err: ApiError = await api.updateDocPermissions(docId, {
+      users: {
+        'user3@getgrist.com': 'owners',
+      }
+    }).catch(e => e);
+    // Advice should be to add users as members.
+    assert.sameMembers(err.details!.tips!.map(tip => tip.action), ['add-members']);
+
+    // Now switch to a product that looks like a personal site
+    await setFeatures({maxSharesPerDoc: 2, workspaces: true, maxWorkspacesPerOrg: 1});
+    err = await api.updateDocPermissions(docId, {
+      users: {
+        'user3@getgrist.com': 'owners',
+      }
+    }).catch(e => e);
+    // Advice should be to upgrade.
+    assert.sameMembers(err.details!.tips!.map(tip => tip.action), ['upgrade']);
+  });
+
+  it('can give good tips when exceeding workspace shares', async function() {
+    await setFeatures({maxSharesPerWorkspace: 2, workspaces: true});
+    const wsId = await api.newWorkspace({name: 'shares'}, 'docs');
+
+    await assert.isFulfilled(api.updateWorkspacePermissions(wsId, {
+      users: {
+        'user1@getgrist.com': 'owners',
+        'user2@getgrist.com': 'viewers',
+      }
+    }));
+    let err: ApiError = await api.updateWorkspacePermissions(wsId, {
+      users: {
+        'user3@getgrist.com': 'owners',
+      }
+    }).catch(e => e);
+    // Advice should be to add users as members.
+    assert.sameMembers(err.details!.tips!.map(tip => tip.action), ['add-members']);
+
+    // Now switch to a product that looks like a personal site (it should not
+    // be possible to share workspaces via UI in this case though)
+    await setFeatures({maxSharesPerWorkspace: 0, workspaces: true, maxWorkspacesPerOrg: 1});
+    err = await api.updateWorkspacePermissions(wsId, {
+      users: {
+        'user3@getgrist.com': 'owners',
+      }
+    }).catch(e => e);
+    // Advice should be to upgrade.
+    assert.sameMembers(err.details!.tips!.map(tip => tip.action), ['upgrade']);
+  });
+
+  it('discounts deleted and soft-deleted documents from quota', async function() {
+    this.timeout(3000);      // This can exceed the default of 2s on Jenkins
+
+    // Reset org to contain no docs, and set limit on docs to 2
+    await resetOrg(api, 'docs');
+    await setFeatures({maxDocsPerOrg: 2, workspaces: true});
+    const wsId = await api.newWorkspace({name: 'work'}, 'docs');
+
+    // Create 2 docs.  Then creating another will fail.
+    const doc1 = await api.newDoc({name: 'doc1'}, wsId);
+    const doc2 = await api.newDoc({name: 'doc2'}, wsId);
+    await assert.isRejected(api.newDoc({name: 'doc3'}, wsId), /No more documents/);
+
+    // Hard-delete one doc, then we can add another.
+    await api.deleteDoc(doc1);
+    const doc3 = await api.newDoc({name: 'doc3'}, wsId);
+
+    // Soft-delete one doc, then we can add another.
+    await api.softDeleteDoc(doc2);
+    await api.newDoc({name: 'doc4'}, wsId);
+
+    // Check we can neither create nor recover a doc when full again.
+    await assert.isRejected(api.newDoc({name: 'doc5'}, wsId), /No more documents/);
+    await assert.isRejected(api.undeleteDoc(doc2), /No more documents/);
+
+    // Check that if we make some space we can recover a doc.
+    await api.softDeleteDoc(doc3);
+    await api.undeleteDoc(doc2);
+  });
+
+  it('can enforce limits on total attachment file size', async function() {
+    this.timeout(4000);
+
+    // Each attachment in this test will have one byte, so essentially we're limiting to two attachments
+    await setFeatures({baseMaxAttachmentsBytesPerDocument: 2});
+
+    const workspaces = await api.getOrgWorkspaces('current');
+    const docId = await api.newDoc({name: 'doc1'}, workspaces[0].id);
+    await api.applyUserActions(docId, [["ModifyColumn", "Table1", "A", {type: "Attachments"}]]);
+    const docApi = api.getDocAPI(docId);
+
+    // Add a cell referencing the attachments we're about to create.
+    // This ensures that they won't be immediately treated as soft-deleted and ignored in the total size calculation.
+    // Otherwise the uploads after this would succeed even if duplicate attachments were counted twice.
+    const rowIds = await docApi.addRows("Table1", {A: [[GristObjCode.List, 1, 2, 3]]});
+    assert.deepEqual(rowIds, [1]);
+
+    // We're limited to 2 attachments, but the attachment 'a' is duplicated so it's only counted once.
+    const attachmentIds = [
+      await docApi.uploadAttachment('a', 'a.txt'),
+      await docApi.uploadAttachment('a', 'a.txt'),
+      await docApi.uploadAttachment('b', 'b.txt'),
+    ];
+    assert.deepEqual(attachmentIds, [1, 2, 3]);
+
+    // Now we're at the limit and trying to upload another attachment is rejected.
+    await assert.isRejected(docApi.uploadAttachment('c', 'c.txt'));
+
+    // Delete one reference to 'a', but there's still another one so we're still at the limit and can't upload more.
+    await docApi.updateRows("Table1", {id: rowIds, A: [[GristObjCode.List, 2, 3]]});
+    await assert.isRejected(docApi.uploadAttachment('c', 'c.txt'));
+
+    // Delete the other reference to 'a' so now there's only one referenced attachment 'b' and we can upload again.
+    await docApi.updateRows("Table1", {id: rowIds, A: [[GristObjCode.List, 3, 4]]});
+    assert.equal(await docApi.uploadAttachment('c', 'c.txt'), 4);
+
+    // Now we're at the limit again with 'b' and 'c' and can't upload further.
+    await assert.isRejected(docApi.uploadAttachment('d', 'd.txt'));
+  });
+
+});
diff --git a/test/gen-server/lib/listing.ts b/test/gen-server/lib/listing.ts
new file mode 100644
index 00000000..ee1fde04
--- /dev/null
+++ b/test/gen-server/lib/listing.ts
@@ -0,0 +1,196 @@
+import {UserAPI} from 'app/common/UserAPI';
+import {assert} from 'chai';
+import {TestServer} from 'test/gen-server/apiUtils';
+import * as testUtils from 'test/server/testUtils';
+
+/**
+ * Tests details of listing workspaces or documents via API.
+ */
+describe('listing', function() {
+  this.timeout(10000);
+  let home: TestServer;
+  testUtils.setTmpLogLevel('error');
+
+  const org: string = 'testy';
+  let api: UserAPI;
+  let viewer: UserAPI;
+  let editor: UserAPI;
+  let ws1: number;
+  let ws2: number;
+  let ws3: number;
+  let doc12: string;
+  let doc13: string;
+
+  before(async function() {
+    home = new TestServer(this);
+    await home.start(['home', 'docs']);
+
+    // Create a test org with some workspaces and docs
+    api = await home.createHomeApi('chimpy', 'docs', true);
+    await api.newOrg({name: org, domain: org});
+    api = await home.createHomeApi('chimpy', org, true);
+    ws1 = await api.newWorkspace({name: 'ws1'}, 'current');
+    ws2 = await api.newWorkspace({name: 'ws2'}, 'current');
+    ws3 = await api.newWorkspace({name: 'ws3'}, 'current');
+    await api.newDoc({name: 'doc11'}, ws1);
+    doc12 = await api.newDoc({name: 'doc12'}, ws1);
+    doc13 = await api.newDoc({name: 'doc13'}, ws1);
+    const doc21 = await api.newDoc({name: 'doc21'}, ws2);
+
+    // add an editor and a viewer to the org.
+    await api.updateOrgPermissions('current', {
+      users: {
+        'kiwi@getgrist.com': 'viewers',
+        'support@getgrist.com': 'editors',
+      }
+    });
+    viewer = await home.createHomeApi('kiwi', org, true);
+    editor = await home.createHomeApi('support', org, true);
+
+    // add another user as an owner of two docs and two workspaces.
+    await api.updateDocPermissions(doc12, {
+      users: {'charon@getgrist.com': 'owners'}
+    });
+    await api.updateDocPermissions(doc13, {
+      users: {'charon@getgrist.com': 'owners'}
+    });
+    await api.updateWorkspacePermissions(ws2, {
+      users: {'charon@getgrist.com': 'owners'}
+    });
+    await api.updateWorkspacePermissions(ws3, {
+      users: {'charon@getgrist.com': 'owners'}
+    });
+
+    // Have that user remove or limit everyone else's access to those docs and workspaces.
+    const charon = await home.createHomeApi('charon', org, true);
+    await charon.updateWorkspacePermissions(ws2, {
+      users: {'chimpy@getgrist.com': null} // remove chimpy from ws2
+    });
+    await charon.updateDocPermissions(doc12, {
+      maxInheritedRole: null,
+      users: {'chimpy@getgrist.com': null} // remove chimpy's direct access
+    });
+    await charon.updateDocPermissions(doc13, {
+      maxInheritedRole: 'viewers',
+      users: {'chimpy@getgrist.com': null} // remove chimpy's direct access
+    });
+    await charon.updateDocPermissions(doc21, {
+      users: {'chimpy@getgrist.com': null} // remove chimpy's direct access
+    });
+    await charon.updateWorkspacePermissions(ws2, {
+      maxInheritedRole: null,
+      users: {'chimpy@getgrist.com': null} // remove chimpy's direct access
+    });
+    await charon.updateWorkspacePermissions(ws3, {
+      maxInheritedRole: 'viewers',
+      users: {'chimpy@getgrist.com': null} // remove chimpy's direct access
+    });
+  });
+
+  after(async function() {
+    await api.deleteOrg('testy');
+    await home.stop();
+  });
+
+  // Check lists acquired via getWorkspace or via getOrgWorkspaces.
+  for (const method of ['getWorkspace', 'getOrgWorkspaces'] as const) {
+
+    it(`editors and owners can list docs they cannot view with ${method}`, async function() {
+      async function list(user: UserAPI) {
+        if (method === 'getWorkspace') { return user.getWorkspace(ws1); }
+        return (await user.getOrgWorkspaces('current')).find(ws => ws.name === 'ws1')!;
+      }
+
+      // Check owner of a workspace can see a doc they don't have access to listed (doc12).
+      let listing = await list(api);
+      assert.lengthOf(listing.docs, 3);
+      assert.equal(listing.docs[0].name, 'doc11');
+      assert.equal(listing.docs[0].access, 'owners');
+      assert.equal(listing.docs[1].name, 'doc12');
+      assert.equal(listing.docs[1].access, null);
+      assert.equal(listing.docs[2].name, 'doc13');
+      assert.equal(listing.docs[2].access, 'viewers');
+
+      // Editor's perspective should be like the owner.
+      listing = await list(editor);
+      assert.lengthOf(listing.docs, 3);
+      assert.equal(listing.docs[0].name, 'doc11');
+      assert.equal(listing.docs[0].access, 'editors');
+      assert.equal(listing.docs[1].name, 'doc12');
+      assert.equal(listing.docs[1].access, null);
+      assert.equal(listing.docs[2].name, 'doc13');
+      assert.equal(listing.docs[2].access, 'viewers');
+
+      // Viewer's perspective should omit docs user has no access to.
+      listing = await list(viewer);
+      assert.lengthOf(listing.docs, 2);
+      assert.equal(listing.docs[0].name, 'doc11');
+      assert.equal(listing.docs[0].access, 'viewers');
+      assert.equal(listing.docs[1].name, 'doc13');
+      assert.equal(listing.docs[1].access, 'viewers');
+    });
+  }
+
+  it('editors and owners CANNOT list workspaces they cannot view', async function() {
+    async function list(user: UserAPI) {
+      return (await user.getOrgWorkspaces('current')).filter(ws => ws.name.startsWith('ws'));
+    }
+
+    // Check owner of an org CANNOT see a workspace they don't have access to listed (ws2).
+    let listing = await list(api);
+    assert.lengthOf(listing, 2);
+    assert.equal(listing[0].name, 'ws1');
+    assert.equal(listing[0].access, 'owners');
+    assert.equal(listing[1].name, 'ws3');
+    assert.equal(listing[1].access, 'viewers');
+
+    // Viewer's perspective should be similar.
+    listing = await list(viewer);
+    assert.lengthOf(listing, 2);
+    assert.equal(listing[0].name, 'ws1');
+    assert.equal(listing[0].access, 'viewers');
+    assert.equal(listing[1].name, 'ws3');
+    assert.equal(listing[1].access, 'viewers');
+  });
+
+  // Make sure empty workspaces do not get filtered out of listings.
+  it('lists empty workspaces', async function() {
+    // We'll need a second user for some operations.
+    const charon = await home.createHomeApi('charon', org, true);
+
+    // Make an empty workspace.
+    await api.newWorkspace({name: 'wsEmpty'}, 'current');
+
+    // Make a workspace with a single, inaccessible doc.
+    const wsWithDoc = await api.newWorkspace({name: 'wsWithDoc'}, 'current');
+    const docInaccessible = await api.newDoc({name: 'inaccessible'}, wsWithDoc);
+    // Add another user as an owner of the doc.
+    await api.updateDocPermissions(docInaccessible, {
+      users: {'charon@getgrist.com': 'owners'}
+    });
+    // Now remove everyone else's access.
+    await charon.updateDocPermissions(docInaccessible, {
+      maxInheritedRole: null
+    });
+
+    // Make an inaccessible workspace.
+    const wsInaccessible = await api.newWorkspace({name: 'wsInaccessible'}, 'current');
+    // Add another user as an owner of the workspace.
+    await api.updateWorkspacePermissions(wsInaccessible, {
+      users: {'charon@getgrist.com': 'owners'}
+    });
+    // Now remove everyone else's access.
+    await charon.updateWorkspacePermissions(wsInaccessible, {
+      maxInheritedRole: null
+    });
+
+    for (const user of [api, editor, viewer]) {
+      // Make sure both accessible workspaces are present in getOrgWorkspaces list,
+      // and don't get filtered out just because they are empty.
+      const listing = await user.getOrgWorkspaces('current');
+      const names = listing.map(ws => ws.name);
+      assert.includeMembers(names, ['wsEmpty', 'wsWithDoc']);
+      assert.notInclude(names, ['wsInaccessible']);
+    }
+  });
+});
diff --git a/test/gen-server/lib/mergedOrgs.ts b/test/gen-server/lib/mergedOrgs.ts
new file mode 100644
index 00000000..b66860fc
--- /dev/null
+++ b/test/gen-server/lib/mergedOrgs.ts
@@ -0,0 +1,104 @@
+import {Workspace} from 'app/common/UserAPI';
+import {HomeDBManager} from 'app/gen-server/lib/homedb/HomeDBManager';
+import {FlexServer} from 'app/server/lib/FlexServer';
+import {main as mergedServerMain} from 'app/server/mergedServerMain';
+import axios from 'axios';
+import {assert} from 'chai';
+import {createInitialDb, removeConnection, setUpDB} from 'test/gen-server/seed';
+import {configForUser, createUser, setPlan} from 'test/gen-server/testUtils';
+import * as testUtils from 'test/server/testUtils';
+
+describe('mergedOrgs', function() {
+  let home: FlexServer;
+  let dbManager: HomeDBManager;
+  let homeUrl: string;
+  let sharedOrgDomain: string;
+  let sharedDocId: string;
+
+  testUtils.setTmpLogLevel('error');
+
+  before(async function() {
+    setUpDB(this);
+    await createInitialDb();
+    home = await mergedServerMain(0, ["home", "docs"],
+                                  {logToConsole: false, externalStorage: false});
+    dbManager = home.getHomeDBManager();
+    homeUrl = home.getOwnUrl();
+  });
+
+  after(async function() {
+    await home.close();
+    await removeConnection();
+  });
+
+  it('can list all shared workspaces from personal orgs', async function() {
+    // Org "0" or "docs" is a special pseudo-org, with the merged results of all
+    // workspaces in personal orgs that user has access to.
+    let resp = await axios.get(`${homeUrl}/api/orgs/0/workspaces`, configForUser('chimpy'));
+    assert.equal(resp.status, 200);
+    // See only workspaces in Chimpy's personal org so far.
+    assert.sameMembers(resp.data.map((w: Workspace) => w.name), ['Public', 'Private']);
+    // Grant Chimpy access to Kiwi's personal org, and add a workspace to it.
+    const kiwilandOrgId = await dbManager.testGetId('Kiwiland');
+    resp = await axios.patch(`${homeUrl}/api/orgs/${kiwilandOrgId}/access`, {
+      delta: {users: {'chimpy@getgrist.com': 'editors'}}
+    }, configForUser('kiwi'));
+    resp = await axios.post(`${homeUrl}/api/orgs/${kiwilandOrgId}/workspaces`, {
+      name: 'Kiwidocs'
+    }, configForUser('kiwi'));
+    resp = await axios.get(`${homeUrl}/api/orgs/0/workspaces`, configForUser('chimpy'));
+    assert.sameMembers(resp.data.map((w: Workspace) => w.name), ['Private', 'Public', 'Kiwidocs']);
+
+    // Create a new user with two workspaces, add chimpy to a document within
+    // one of them, and make sure chimpy sees that workspace.
+    const samHome = await createUser(dbManager, 'Sam');
+    await setPlan(dbManager, samHome, 'Free');
+    sharedOrgDomain = samHome.domain;
+    // A private workspace/doc that Sam won't share.
+    resp = await axios.post(`${homeUrl}/api/orgs/${samHome.id}/workspaces`, {
+      name: 'SamPrivateStuff'
+    }, configForUser('sam'));
+    assert.equal(resp.status, 200);
+    let wsId = resp.data;
+    resp = await axios.post(`${homeUrl}/api/workspaces/${wsId}/docs`, {
+      name: 'SamPrivateDoc'
+    }, configForUser('sam'));
+    assert.equal(resp.status, 200);
+    // A workspace/doc that Sam will share with Chimpy.
+    resp = await axios.post(`${homeUrl}/api/orgs/${samHome.id}/workspaces`, {
+      name: 'SamStuff'
+    }, configForUser('sam'));
+    assert.equal(resp.status, 200);
+    wsId = resp.data!;
+    resp = await axios.post(`${homeUrl}/api/workspaces/${wsId}/docs`, {
+      name: 'SamDoc'
+    }, configForUser('sam'));
+    assert.equal(resp.status, 200);
+    sharedDocId = resp.data!;
+    resp = await axios.patch(`${homeUrl}/api/docs/${sharedDocId}/access`, {
+      delta: {users: {'chimpy@getgrist.com': 'viewers'}}
+    }, configForUser('sam'));
+    assert.equal(resp.status, 200);
+    resp = await axios.get(`${homeUrl}/api/orgs/0/workspaces`, configForUser('chimpy'));
+    const sharedWss = ['Private', 'Public', 'Kiwidocs', 'SamStuff'];
+    assert.sameMembers(resp.data.map((w: Workspace) => w.name), sharedWss);
+
+    // Check that all this is visible from docs domain expressed in different ways.
+    resp = await axios.get(`${homeUrl}/o/docs/api/orgs/current/workspaces`, configForUser('chimpy'));
+    assert.sameMembers(resp.data.map((w: Workspace) => w.name), sharedWss);
+    resp = await axios.get(`${homeUrl}/api/orgs/docs/workspaces`, configForUser('chimpy'));
+    assert.sameMembers(resp.data.map((w: Workspace) => w.name), sharedWss);
+  });
+
+  it('can access a document under merged domain', async function() {
+    let resp = await axios.get(`${homeUrl}/o/docs/api/docs/${sharedDocId}/tables/Table1/data`,
+                               configForUser('chimpy'));
+    assert.equal(resp.status, 200);
+    resp = await axios.get(`${homeUrl}/o/${sharedOrgDomain}/api/docs/${sharedDocId}/tables/Table1/data`,
+                           configForUser('chimpy'));
+    assert.equal(resp.status, 200);
+    resp = await axios.get(`${homeUrl}/o/nasa/api/docs/${sharedDocId}/tables/Table1/data`,
+                           configForUser('chimpy'));
+    assert.equal(resp.status, 404);
+  });
+});
diff --git a/test/gen-server/lib/prefs.ts b/test/gen-server/lib/prefs.ts
new file mode 100644
index 00000000..3428405b
--- /dev/null
+++ b/test/gen-server/lib/prefs.ts
@@ -0,0 +1,132 @@
+import {UserAPI, UserAPIImpl} from 'app/common/UserAPI';
+import {assert} from 'chai';
+import {TestServer} from 'test/gen-server/apiUtils';
+import * as testUtils from 'test/server/testUtils';
+
+describe('prefs', function() {
+  this.timeout(60000);
+  let home: TestServer;
+  testUtils.setTmpLogLevel('error');
+  let owner: UserAPIImpl;
+  let guest: UserAPI;
+  let stranger: UserAPI;
+
+  before(async function() {
+    home = new TestServer(this);
+    await home.start(['home', 'docs']);
+    const api = await home.createHomeApi('chimpy', 'docs', true);
+    await api.newOrg({name: 'testy', domain: 'testy'});
+    owner = await home.createHomeApi('chimpy', 'testy', true);
+    const ws = await owner.newWorkspace({name: 'ws'}, 'current');
+    await owner.updateWorkspacePermissions(ws, { users: { 'charon@getgrist.com': 'viewers' } });
+    guest = await home.createHomeApi('charon', 'testy', true);
+    stranger = await home.createHomeApi('support', 'testy', true, false);
+  });
+
+  after(async function() {
+    const api = await home.createHomeApi('chimpy', 'docs');
+    await api.deleteOrg('testy');
+    await home.stop();
+  });
+
+  it('can be set as combo orgUserPrefs when owner or guest', async function() {
+    await owner.updateOrg('current', {
+      userOrgPrefs: {placeholder: 'for owner'},
+    });
+    await guest.updateOrg('current', {
+      userOrgPrefs: {placeholder: 'for guest'},
+    });
+    await assert.isRejected(stranger.updateOrg('current', {
+      userOrgPrefs: {placeholder: 'for stranger'},
+    }), /access denied/);
+    assert.equal((await owner.getOrg('current')).userOrgPrefs?.placeholder, 'for owner');
+    assert.equal((await guest.getOrg('current')).userOrgPrefs?.placeholder, 'for guest');
+    await assert.isRejected(stranger.getOrg('current'), /access denied/);
+  });
+
+  it('can be updated as combo orgUserPrefs when owner or guest', async function() {
+    await owner.updateOrg('current', {
+      userOrgPrefs: {placeholder: 'for owner2'},
+    });
+    await guest.updateOrg('current', {
+      userOrgPrefs: {placeholder: 'for guest2'},
+    });
+    await assert.isRejected(stranger.updateOrg('current', {
+      userOrgPrefs: {placeholder: 'for stranger2'},
+    }), /access denied/);
+    assert.equal((await owner.getOrg('current')).userOrgPrefs?.placeholder, 'for owner2');
+    assert.equal((await guest.getOrg('current')).userOrgPrefs?.placeholder, 'for guest2');
+    await assert.isRejected(stranger.getOrg('current'), /access denied/);
+  });
+
+  it('can be set as orgPrefs when owner', async function() {
+    await owner.updateOrg('current', {
+      orgPrefs: {placeholder: 'general'},
+    });
+    await assert.isRejected(guest.updateOrg('current', {
+      orgPrefs: {placeholder: 'general!'},
+    }), /access denied/);
+    await assert.isRejected(stranger.updateOrg('current', {
+      orgPrefs: {placeholder: 'general!'},
+    }), /access denied/);
+    assert.equal((await owner.getOrg('current')).orgPrefs?.placeholder, 'general');
+    assert.equal((await guest.getOrg('current')).orgPrefs?.placeholder, 'general');
+    await assert.isRejected(stranger.getOrg('current'), /access denied/);
+  });
+
+  it('can be updated as orgPrefs when owner', async function() {
+    await owner.updateOrg('current', {
+      orgPrefs: {placeholder: 'general2'},
+    });
+    await assert.isRejected(guest.updateOrg('current', {
+      orgPrefs: {placeholder: 'general2!'},
+    }), /access denied/);
+    await assert.isRejected(stranger.updateOrg('current', {
+      orgPrefs: {placeholder: 'general2!'},
+    }), /access denied/);
+    assert.equal((await owner.getOrg('current')).orgPrefs?.placeholder, 'general2');
+    assert.equal((await guest.getOrg('current')).orgPrefs?.placeholder, 'general2');
+    await assert.isRejected(stranger.getOrg('current'), /access denied/);
+  });
+
+  it('can set as userPrefs when owner or guest', async function() {
+    await owner.updateOrg('current', {
+      userPrefs: {placeholder: 'userPrefs for owner'},
+    });
+    await guest.updateOrg('current', {
+      userPrefs: {placeholder: 'userPrefs for guest'},
+    });
+    await assert.isRejected(stranger.updateOrg('current', {
+      userPrefs: {placeholder: 'for stranger'},
+    }), /access denied/);
+    assert.equal((await owner.getOrg('current')).userPrefs?.placeholder, 'userPrefs for owner');
+    assert.equal((await guest.getOrg('current')).userPrefs?.placeholder, 'userPrefs for guest');
+    await assert.isRejected(stranger.getOrg('current'), /access denied/);
+  });
+
+  it('can be accessed as userPrefs on other orgs', async function() {
+    const owner2 = await home.createHomeApi('chimpy', 'docs', true);
+    const guest2 = await home.createHomeApi('charon', 'docs', true);
+    const stranger2 = await home.createHomeApi('support', 'docs', true);
+    assert.equal((await owner2.getOrg('current')).userPrefs?.placeholder, 'userPrefs for owner');
+    assert.equal((await owner2.getOrg('current')).userOrgPrefs?.placeholder, undefined);
+    assert.equal((await owner2.getOrg('current')).orgPrefs?.placeholder, undefined);
+
+    assert.equal((await guest2.getOrg('current')).userPrefs?.placeholder, 'userPrefs for guest');
+    assert.equal((await guest2.getOrg('current')).userOrgPrefs?.placeholder, undefined);
+    assert.equal((await guest2.getOrg('current')).orgPrefs?.placeholder, undefined);
+
+    assert.equal((await stranger2.getOrg('current')).userPrefs?.placeholder, undefined);
+    assert.equal((await stranger2.getOrg('current')).userOrgPrefs?.placeholder, undefined);
+    assert.equal((await stranger2.getOrg('current')).orgPrefs?.placeholder, undefined);
+  });
+
+  it('can be accessed as prefs from active session', async function() {
+    const owner3 = await home.createHomeApi('chimpy', 'docs', true);
+    const guest3 = await home.createHomeApi('charon', 'docs', true);
+    const stranger3 = await home.createHomeApi('support', 'docs', true);
+    assert.equal((await owner3.getSessionActive()).user.prefs?.placeholder, 'userPrefs for owner');
+    assert.equal((await guest3.getSessionActive()).user.prefs?.placeholder, 'userPrefs for guest');
+    assert.equal((await stranger3.getSessionActive()).user.prefs?.placeholder, undefined);
+  });
+});
diff --git a/test/gen-server/lib/previewer.ts b/test/gen-server/lib/previewer.ts
new file mode 100644
index 00000000..6671c384
--- /dev/null
+++ b/test/gen-server/lib/previewer.ts
@@ -0,0 +1,135 @@
+import {Organization} from 'app/gen-server/entity/Organization';
+import {HomeDBManager} from 'app/gen-server/lib/homedb/HomeDBManager';
+import axios from 'axios';
+import {AxiosRequestConfig} from 'axios';
+import {assert} from 'chai';
+import {TestServer} from 'test/gen-server/apiUtils';
+import {configForUser} from 'test/gen-server/testUtils';
+import * as testUtils from 'test/server/testUtils';
+
+
+const previewer = configForUser('thumbnail');
+
+function permit(permitKey: string): AxiosRequestConfig {
+  return {
+    responseType: 'json',
+    validateStatus: (status: number) => true,
+    headers: {
+      Permit: permitKey
+    }
+  };
+}
+
+describe('previewer', function() {
+
+  let home: TestServer;
+  let dbManager: HomeDBManager;
+  let homeUrl: string;
+
+  testUtils.setTmpLogLevel('error');
+
+  before(async function() {
+    home = new TestServer(this);
+    await home.start(['home', 'docs']);
+    dbManager = home.dbManager;
+    homeUrl = home.serverUrl;
+    // for these tests, give the previewer an api key.
+    await dbManager.connection.query(`update users set api_key = 'api_key_for_thumbnail' where name = 'Preview'`);
+  });
+
+  after(async function() {
+    await home.stop();
+  });
+
+  it('has view access to all orgs', async function() {
+    const resp = await axios.get(`${homeUrl}/api/orgs`, previewer);
+    assert.equal(resp.status, 200);
+    const orgs: any[] = resp.data;
+    assert.lengthOf(orgs, await Organization.count());
+    orgs.forEach((org: any) => assert.equal(org.access, 'viewers'));
+  });
+
+  it('has view access to workspaces and docs', async function() {
+    const oid = await dbManager.testGetId('NASA');
+    const resp = await axios.get(`${homeUrl}/api/orgs/${oid}/workspaces`, previewer);
+    assert.equal(resp.status, 200);
+    const workspaces: any[] = resp.data;
+    assert.lengthOf(workspaces, 2);
+    workspaces.forEach((ws: any) => {
+      assert.equal(ws.access, 'viewers');
+      const docs: any[] = ws.docs;
+      docs.forEach((doc: any) => assert.equal(doc.access, 'viewers'));
+    });
+  });
+
+  it('cannot delete or update docs and workspaces', async function() {
+    const oid = await dbManager.testGetId('NASA');
+    let resp = await axios.get(`${homeUrl}/api/orgs/${oid}/workspaces`, previewer);
+    assert.equal(resp.status, 200);
+
+    const wsId = resp.data[0].id;
+    const docId = resp.data[0].docs[0].id;
+
+    resp = await axios.get(`${homeUrl}/api/docs/${docId}`, previewer);
+    assert.equal(resp.status, 200);
+    resp = await axios.delete(`${homeUrl}/api/docs/${docId}`, previewer);
+    assert.equal(resp.status, 403);
+    resp = await axios.patch(`${homeUrl}/api/docs/${docId}`, {name: 'diff'}, previewer);
+    assert.equal(resp.status, 403);
+
+    resp = await axios.get(`${homeUrl}/api/workspaces/${wsId}`, previewer);
+    assert.equal(resp.status, 200);
+    resp = await axios.delete(`${homeUrl}/api/workspaces/${wsId}`, previewer);
+    assert.equal(resp.status, 403);
+    resp = await axios.patch(`${homeUrl}/api/workspaces/${wsId}`, {name: 'diff'}, previewer);
+    assert.equal(resp.status, 403);
+  });
+
+  it('can delete workspaces and docs using permits', async function() {
+    const oid = await dbManager.testGetId('NASA');
+    let resp = await axios.get(`${homeUrl}/api/orgs/${oid}/workspaces`, previewer);
+    assert.equal(resp.status, 200);
+
+    const wsId = resp.data[0].id;
+    const docId = resp.data[0].docs[0].id;
+
+    const store = home.getWorkStore().getPermitStore('internal');
+    const goodDocPermit = await store.setPermit({docId});
+    const badDocPermit = await store.setPermit({docId: 'dud'});
+    const goodWsPermit = await store.setPermit({workspaceId: wsId});
+    const badWsPermit = await store.setPermit({workspaceId: wsId + 1});
+
+    // Check that external store is no good for internal use.
+    const externalStore = home.getWorkStore().getPermitStore('external');
+    const externalDocPermit = await externalStore.setPermit({docId});
+    resp = await axios.get(`${homeUrl}/api/docs/${docId}`, permit(externalDocPermit));
+    //assert.equal(resp.status, 401);
+
+    resp = await axios.get(`${homeUrl}/api/docs/${docId}`, permit(badDocPermit));
+    assert.equal(resp.status, 403);
+    resp = await axios.delete(`${homeUrl}/api/docs/${docId}`, permit(badDocPermit));
+    assert.equal(resp.status, 403);
+    resp = await axios.delete(`${homeUrl}/api/docs/${docId}`, permit(goodWsPermit));
+    assert.equal(resp.status, 403);
+    resp = await axios.get(`${homeUrl}/api/docs/${docId}`, permit(goodDocPermit));
+    assert.equal(resp.status, 403);
+    resp = await axios.patch(`${homeUrl}/api/docs/${docId}`, {name: 'diff'}, permit(goodDocPermit));
+    assert.equal(resp.status, 403);
+    resp = await axios.delete(`${homeUrl}/api/docs/${docId}`, permit(goodDocPermit));
+    assert.equal(resp.status, 200);
+
+    resp = await axios.get(`${homeUrl}/api/workspaces/${wsId}`, permit(badWsPermit));
+    assert.equal(resp.status, 403);
+    resp = await axios.delete(`${homeUrl}/api/workspaces/${wsId}`, permit(badWsPermit));
+    assert.equal(resp.status, 403);
+    resp = await axios.delete(`${homeUrl}/api/workspaces/${wsId}`, permit(goodDocPermit));
+    assert.equal(resp.status, 403);
+    resp = await axios.get(`${homeUrl}/api/workspaces/${wsId}`, permit(goodWsPermit));
+    assert.equal(resp.status, 403);
+    resp = await axios.patch(`${homeUrl}/api/workspaces/${wsId}`, {name: 'diff'}, permit(goodWsPermit));
+    assert.equal(resp.status, 403);
+    resp = await axios.delete(`${homeUrl}/api/workspaces/${wsId}`, permit(goodWsPermit));
+    assert.equal(resp.status, 200);
+
+  });
+});
diff --git a/test/gen-server/lib/removedAt.ts b/test/gen-server/lib/removedAt.ts
new file mode 100644
index 00000000..2e990707
--- /dev/null
+++ b/test/gen-server/lib/removedAt.ts
@@ -0,0 +1,440 @@
+import {BaseAPI} from 'app/common/BaseAPI';
+import {UserAPI, Workspace} from 'app/common/UserAPI';
+import {assert} from 'chai';
+import flatten = require('lodash/flatten');
+import sortBy = require('lodash/sortBy');
+import {TestServer} from 'test/gen-server/apiUtils';
+import * as testUtils from 'test/server/testUtils';
+
+describe('removedAt', function() {
+  let home: TestServer;
+  testUtils.setTmpLogLevel('error');
+
+  before(async function() {
+    home = new TestServer(this);
+    await home.start(['home', 'docs']);
+    const api = await home.createHomeApi('chimpy', 'docs');
+    await api.newOrg({name: 'testy', domain: 'testy'});
+  });
+
+  after(async function() {
+    this.timeout(100000);
+    const api = await home.createHomeApi('chimpy', 'docs');
+    await api.deleteOrg('testy');
+    await home.stop();
+  });
+
+  function docNames(data: Workspace|Workspace[]) {
+    if ('docs' in data) {
+      return data.docs.map(doc => doc.name).sort();
+    }
+    return flatten(
+      sortBy(data, 'name')
+        .map(ws => ws.docs.map(d => `${ws.name}:${d.name}`).sort()));
+  }
+
+  function workspaceNames(data: Workspace[]) {
+    return data.map(ws => ws.name).sort();
+  }
+
+  describe('scenario', function() {
+    const org: string = 'testy';
+    let api: UserAPI;  // regular api
+    let xapi: UserAPI; // api for soft-deleted resources
+    let bapi: BaseAPI; // api cast to allow custom requests
+    let ws1: number;
+    let ws2: number;
+    let ws3: number;
+    let ws4: number;
+    let doc11: string;
+    let doc21: string;
+    let doc12: string;
+    let doc31: string;
+
+    before(async function() {
+      api = await home.createHomeApi('chimpy', org);
+      xapi = api.forRemoved();
+      bapi = api as unknown as BaseAPI;
+      // Get rid of home workspace
+      for (const ws of await api.getOrgWorkspaces('current')) {
+        await api.deleteWorkspace(ws.id);
+      }
+      // Make two workspaces with two docs apiece, one workspace with one doc,
+      // and one empty workspace
+      ws1 = await api.newWorkspace({name: 'ws1'}, 'current');
+      ws2 = await api.newWorkspace({name: 'ws2'}, 'current');
+      ws3 = await api.newWorkspace({name: 'ws3'}, 'current');
+      ws4 = await api.newWorkspace({name: 'ws4'}, 'current');
+      doc11 = await api.newDoc({name: 'doc11'}, ws1);
+      doc12 = await api.newDoc({name: 'doc12'}, ws1);
+      doc21 = await api.newDoc({name: 'doc21'}, ws2);
+      await api.newDoc({name: 'doc22'}, ws2);
+      doc31 = await api.newDoc({name: 'doc31'}, ws3);
+    });
+
+    it('hides soft-deleted docs from regular api', async function() {
+      // This can be too low for running this test directly (when database needs to be created).
+      this.timeout(10000);
+
+      // Check that doc11 is visible via regular api
+      assert.equal((await api.getDoc(doc11)).name, 'doc11');
+      assert.typeOf((await api.getDoc(doc11)).removedAt, 'undefined');
+      assert.deepEqual(docNames(await api.getOrgWorkspaces('current')),
+                       ['ws1:doc11', 'ws1:doc12', 'ws2:doc21', 'ws2:doc22', 'ws3:doc31']);
+      assert.deepEqual(workspaceNames(await api.getOrgWorkspaces('current')),
+                       ['ws1', 'ws2', 'ws3', 'ws4']);
+      assert.deepEqual(docNames(await api.getWorkspace(ws1)), ['doc11', 'doc12']);
+      assert.deepEqual(docNames(await api.getWorkspace(ws2)), ['doc21', 'doc22']);
+      assert.deepEqual(docNames(await api.getWorkspace(ws3)), ['doc31']);
+      assert.deepEqual(docNames(await api.getWorkspace(ws4)), []);
+
+      // Soft-delete doc11, leaving one doc in ws1
+      await api.softDeleteDoc(doc11);
+
+      // Check that doc11 is no longer visible via regular api
+      await assert.isRejected(api.getDoc(doc11), /not found/);
+      assert.deepEqual(docNames(await api.getWorkspace(ws1)), ['doc12']);
+      assert.deepEqual(docNames(await api.getOrgWorkspaces('current')),
+                       ['ws1:doc12', 'ws2:doc21', 'ws2:doc22', 'ws3:doc31']);
+
+      // Check that various related endpoints are forbidden
+      let docApi = api.getDocAPI(doc11);
+      await assert.isRejected(docApi.getSnapshots(), /404.*document is deleted/i);
+      await assert.isRejected(docApi.getRows('Table1'), /404.*document is deleted/i);
+      await assert.isRejected(docApi.forceReload(), /404.*document is deleted/i);
+
+      // Check that doc11 is visible via forRemoved api
+      assert.equal((await xapi.getDoc(doc11)).name, 'doc11');
+      assert.typeOf((await xapi.getDoc(doc11)).removedAt, 'string');
+      assert.deepEqual(docNames(await xapi.getWorkspace(ws1)), ['doc11']);
+      await assert.isRejected(xapi.getWorkspace(ws2), /not found/);
+      assert.deepEqual(docNames(await xapi.getOrgWorkspaces('current')),
+                       ['ws1:doc11']);
+      assert.deepEqual(workspaceNames(await xapi.getOrgWorkspaces('current')),
+                       ['ws1']);
+
+      docApi = xapi.getDocAPI(doc11);
+      await assert.isFulfilled(docApi.getSnapshots());
+      await assert.isFulfilled(docApi.getRows('Table1'));
+      await assert.isFulfilled(docApi.forceReload());
+    });
+
+    it('lists workspaces even with all docs soft-deleted', async function() {
+      // Soft-delete doc12, leaving ws1 empty
+      await api.softDeleteDoc(doc12);
+      // Soft-delete doc31, leaving ws3 empty
+      await api.softDeleteDoc(doc31);
+
+      // Check docs are not visible, but workspaces are
+      await assert.isRejected(api.getDoc(doc12), /not found/);
+      await assert.isRejected(api.getDoc(doc31), /not found/);
+      assert.deepEqual(docNames(await api.getOrgWorkspaces('current')), ['ws2:doc21', 'ws2:doc22']);
+      assert.deepEqual(workspaceNames(await api.getOrgWorkspaces('current')),
+                       ['ws1', 'ws2', 'ws3', 'ws4']);
+      assert.deepEqual(docNames(await api.getWorkspace(ws1)), []);
+      assert.deepEqual(docNames(await api.getWorkspace(ws3)), []);
+
+      // Check docs are visible via forRemoved api
+      assert.equal((await xapi.getDoc(doc12)).name, 'doc12');
+      assert.typeOf((await xapi.getDoc(doc12)).removedAt, 'string');
+      assert.equal((await xapi.getDoc(doc31)).name, 'doc31');
+      assert.typeOf((await xapi.getDoc(doc31)).removedAt, 'string');
+      assert.equal((await xapi.getWorkspace(ws1)).name, 'ws1');
+      assert.typeOf((await xapi.getWorkspace(ws1)).removedAt, 'undefined');
+      assert.deepEqual(docNames(await xapi.getWorkspace(ws1)), ['doc11', 'doc12']);
+      assert.deepEqual(docNames(await xapi.getWorkspace(ws3)), ['doc31']);
+      await assert.isRejected(xapi.getWorkspace(ws2), /not found/);
+      assert.deepEqual(docNames(await xapi.getOrgWorkspaces('current')),
+                       ['ws1:doc11', 'ws1:doc12', 'ws3:doc31']);
+      assert.deepEqual(workspaceNames(await xapi.getOrgWorkspaces('current')),
+                       ['ws1', 'ws3']);
+    });
+
+    it('can revert soft-deleted docs', async function() {
+      // Undelete docs
+      await api.undeleteDoc(doc11);
+      await api.undeleteDoc(doc12);
+      await api.undeleteDoc(doc31);
+
+      // Check that doc11 is visible via regular api again
+      assert.equal((await api.getDoc(doc11)).name, 'doc11');
+      assert.typeOf((await api.getDoc(doc11)).removedAt, 'undefined');
+      assert.deepEqual(docNames(await api.getOrgWorkspaces('current')),
+                       ['ws1:doc11', 'ws1:doc12', 'ws2:doc21', 'ws2:doc22', 'ws3:doc31']);
+      assert.deepEqual(docNames(await api.getWorkspace(ws1)), ['doc11', 'doc12']);
+
+      // Check that no "trash" is visible anymore
+      assert.deepEqual(docNames(await xapi.getOrgWorkspaces('current')), []);
+      await assert.isRejected(xapi.getWorkspace(ws1), /not found/);
+      await assert.isRejected(xapi.getWorkspace(ws2), /not found/);
+      await assert.isRejected(xapi.getWorkspace(ws3), /not found/);
+      await assert.isRejected(xapi.getWorkspace(ws4), /not found/);
+    });
+
+    it('hides soft-deleted workspaces from regular api', async function() {
+      // Soft-delete ws1, ws3, and ws4
+      await api.softDeleteWorkspace(ws1);
+      await api.softDeleteWorkspace(ws3);
+      await api.softDeleteWorkspace(ws4);
+
+      // Check that workspaces are no longer visible via regular api
+      await assert.isRejected(api.getDoc(doc11), /not found/);
+      await assert.isRejected(api.getWorkspace(ws1), /not found/);
+      assert.deepEqual(docNames(await api.getWorkspace(ws2)), ['doc21', 'doc22']);
+      await assert.isRejected(api.getDoc(doc31), /not found/);
+      await assert.isRejected(api.getWorkspace(ws3), /not found/);
+      await assert.isRejected(api.getWorkspace(ws4), /not found/);
+      assert.deepEqual(docNames(await api.getOrgWorkspaces('current')),
+                       ['ws2:doc21', 'ws2:doc22']);
+
+      // Check that workspaces are visible via forRemoved api
+      assert.equal((await xapi.getWorkspace(ws1)).name, 'ws1');
+      assert.typeOf((await xapi.getWorkspace(ws1)).removedAt, 'string');
+      assert.equal((await xapi.getDoc(doc11)).name, 'doc11');
+      assert.equal((await xapi.getWorkspace(ws3)).name, 'ws3');
+      assert.typeOf((await xapi.getWorkspace(ws3)).removedAt, 'string');
+      assert.equal((await xapi.getWorkspace(ws4)).name, 'ws4');
+      assert.typeOf((await xapi.getWorkspace(ws4)).removedAt, 'string');
+      // we may not want the following - may want to explicitly set removedAt
+      // on docs within a soft-deleted workspace
+      assert.typeOf((await xapi.getDoc(doc11)).removedAt, 'undefined');
+      assert.typeOf((await xapi.getDoc(doc12)).removedAt, 'undefined');
+      assert.typeOf((await xapi.getDoc(doc31)).removedAt, 'undefined');
+      assert.deepEqual(docNames(await xapi.getWorkspace(ws1)), ['doc11', 'doc12']);
+      await assert.isRejected(xapi.getWorkspace(ws2), /not found/);
+      assert.deepEqual(docNames(await xapi.getWorkspace(ws3)), ['doc31']);
+      assert.deepEqual(docNames(await xapi.getWorkspace(ws4)), []);
+      assert.deepEqual(docNames(await xapi.getOrgWorkspaces('current')),
+                       ['ws1:doc11', 'ws1:doc12', 'ws3:doc31']);
+    });
+
+    it('can combine soft-deleted workspaces and soft-deleted docs', async function() {
+      // Delete a doc in an undeleted workspace, and in a soft-deleted workspace.
+      await api.softDeleteDoc(doc21);
+      await xapi.softDeleteDoc(doc11);
+      assert.deepEqual(docNames(await api.getOrgWorkspaces('current')),
+                       ['ws2:doc22']);
+      assert.deepEqual(docNames(await xapi.getOrgWorkspaces('current')),
+                       ['ws1:doc11', 'ws1:doc12', 'ws2:doc21', 'ws3:doc31']);
+    });
+
+    it('can revert soft-deleted workspaces', async function() {
+      // Undelete workspaces and docs
+      await api.undeleteWorkspace(ws1);
+      await api.undeleteWorkspace(ws3);
+      await api.undeleteWorkspace(ws4);
+      await api.undeleteDoc(doc21);
+      await api.undeleteDoc(doc11);
+
+      // Check that docs are visible via regular api again
+      assert.equal((await api.getDoc(doc11)).name, 'doc11');
+      assert.typeOf((await api.getDoc(doc11)).removedAt, 'undefined');
+      assert.deepEqual(docNames(await api.getOrgWorkspaces('current')),
+                       ['ws1:doc11', 'ws1:doc12', 'ws2:doc21', 'ws2:doc22', 'ws3:doc31']);
+      assert.deepEqual(docNames(await api.getWorkspace(ws1)), ['doc11', 'doc12']);
+
+      // Check that no "trash" is visible anymore
+      assert.deepEqual(docNames(await xapi.getOrgWorkspaces('current')), []);
+    });
+
+
+    // This checks that the following problem is fixed:
+    //   If a document is deleted in a workspace with many other documents, the
+    //   deletion used to take an unreasonable length of time.
+    it('deletes documents reasonably quickly', async function() {
+      this.timeout(15000);
+      const ws = await api.newWorkspace({name: 'speedTest'}, 'testy');
+      // Create a batch of many documents.
+      const docIds = await Promise.all(new Array(50).fill(0).map(() => api.newDoc({name: 'doc'}, ws)));
+      // Explicitly set users on some of the documents.
+      await api.updateDocPermissions(docIds[5], {
+        users: {
+          'test1@getgrist.com': 'viewers',
+        }
+      });
+      await api.updateDocPermissions(docIds[10], {
+        users: {
+          'test2@getgrist.com': 'owners',
+          'test3@getgrist.com': 'editors',
+        }
+      });
+      const userRef = (email: string) => home.dbManager.getUserByLogin(email).then((user) => user!.ref);
+      const idTest1 = (await home.dbManager.getUserByLogin("test1@getgrist.com"))!.id;
+      const idTest2 = (await home.dbManager.getUserByLogin("test2@getgrist.com"))!.id;
+      const idTest3 = (await home.dbManager.getUserByLogin("test3@getgrist.com"))!.id;
+      // Create one extra document, with one extra user.
+      const extraDocId = await api.newDoc({name: 'doc'}, ws);
+      await api.updateDocPermissions(extraDocId, {
+        users: { 'kiwi@getgrist.com': 'viewers' }
+      });
+      assert.deepEqual(await api.getWorkspaceAccess(ws), {
+        "maxInheritedRole": "owners",
+        "users": [
+          {
+            "id": 1,
+            "name": "Chimpy",
+            "email": "chimpy@getgrist.com",
+            "ref": await userRef("chimpy@getgrist.com"),
+            "picture": null,
+            "access": "owners",
+            "parentAccess": "owners",
+            "isMember": true,
+          },
+          {
+            "id": 2,
+            "name": "Kiwi",
+            "email": "kiwi@getgrist.com",
+            "ref": await userRef("kiwi@getgrist.com"),
+            "picture": null,
+            "access": "guests",
+            "parentAccess": null,
+            "isMember": false,
+          },
+          {
+            "id": idTest1,
+            "name": "",
+            "email": "test1@getgrist.com",
+            "ref": await userRef("test1@getgrist.com"),
+            "picture": null,
+            "access": "guests",
+            "parentAccess": null,
+            "isMember": false,
+          },
+          {
+            "id": idTest2,
+            "name": "",
+            "email": "test2@getgrist.com",
+            "ref": await userRef("test2@getgrist.com"),
+            "picture": null,
+            "access": "guests",
+            "parentAccess": null,
+            "isMember": false,
+          },
+          {
+            "id": idTest3,
+            "name": "",
+            "email": "test3@getgrist.com",
+            "ref": await userRef("test3@getgrist.com"),
+            "picture": null,
+            "access": "guests",
+            "parentAccess": null,
+            "isMember": false,
+          }
+        ]
+      });
+      // Delete the batch of documents, retaining the one extra.
+      await Promise.all(docIds.map(docId => api.deleteDoc(docId)));
+      // Make sure the guest from the extra doc is retained, while all others evaporate.
+      assert.deepEqual(await api.getWorkspaceAccess(ws), {
+        "maxInheritedRole": "owners",
+        "users": [
+          {
+            "id": 1,
+            "name": "Chimpy",
+            "email": "chimpy@getgrist.com",
+            "ref": await userRef("chimpy@getgrist.com"),
+            "picture": null,
+            "access": "owners",
+            "parentAccess": "owners",
+            "isMember": true,
+          },
+          {
+            "id": 2,
+            "name": "Kiwi",
+            "email": "kiwi@getgrist.com",
+            "ref": await userRef("kiwi@getgrist.com"),
+            "picture": null,
+            "access": "guests",
+            "parentAccess": null,
+            "isMember": false,
+          }
+        ]
+      });
+    });
+
+    it('does not interfere with DocAuthKey-based caching', async function() {
+      const info = await api.getSessionActive();
+
+      // Flush cache, then try to access doc11 as a removed doc.
+      home.dbManager.flushDocAuthCache();
+      await assert.isRejected(xapi.getDoc(doc11), /not found/);
+
+      // Check that cached authentication is correct.
+      const auth = await home.dbManager.getDocAuthCached({urlId: doc11, userId: info.user.id, org});
+      assert.equal(auth.access, 'owners');
+    });
+
+    it('respects permanent flag on /api/docs/:did/remove', async function() {
+      await bapi.testRequest(`${api.getBaseUrl()}/api/docs/${doc11}/remove`,
+                             {method: 'POST'});
+      await bapi.testRequest(`${api.getBaseUrl()}/api/docs/${doc12}/remove?permanent=1`,
+                             {method: 'POST'});
+      await api.undeleteDoc(doc11);
+      await assert.isRejected(api.undeleteDoc(doc12), /not found/);
+    });
+
+    it('respects permanent flag on /api/workspaces/:wid/remove', async function() {
+      await bapi.testRequest(`${api.getBaseUrl()}/api/workspaces/${ws1}/remove`,
+                             {method: 'POST'});
+      await bapi.testRequest(`${api.getBaseUrl()}/api/workspaces/${ws2}/remove?permanent=1`,
+                             {method: 'POST'});
+      await api.undeleteWorkspace(ws1);
+      await assert.isRejected(api.undeleteWorkspace(ws2), /not found/);
+    });
+
+    it('can hard-delete a soft-deleted document', async function() {
+      const tmp1 = await api.newDoc({name: 'tmp1'}, ws1);
+      const tmp2 = await api.newDoc({name: 'tmp2'}, ws1);
+      await api.softDeleteDoc(tmp1);
+      await api.deleteDoc(tmp1);
+      await api.softDeleteDoc(tmp2);
+      await bapi.testRequest(`${api.getBaseUrl()}/api/docs/${tmp2}/remove?permanent=1`,
+                             {method: 'POST'});
+      await assert.isRejected(api.undeleteDoc(tmp1));
+      await assert.isRejected(api.undeleteDoc(tmp2));
+    });
+
+    it('can hard-delete a soft-deleted workspace', async function() {
+      const tmp1 = await api.newWorkspace({name: 'tmp1'}, 'current');
+      const tmp2 = await api.newWorkspace({name: 'tmp2'}, 'current');
+      await api.softDeleteWorkspace(tmp1);
+      await api.deleteWorkspace(tmp1);
+      await api.softDeleteWorkspace(tmp2);
+      await bapi.testRequest(`${api.getBaseUrl()}/api/workspaces/${tmp2}/remove?permanent=1`,
+                             {method: 'POST'});
+      await assert.isRejected(api.undeleteWorkspace(tmp1));
+      await assert.isRejected(api.undeleteWorkspace(tmp2));
+    });
+
+    // This checks that the following problem is fixed:
+    //   If I shared a doc with a friend, and then soft-deleted a doc in the same workspace,
+    //   that friend used to see the workspace in their trash (empty, but there).
+    it('does not show workspaces for docs user does not have access to', async function() {
+      // Make two docs in a workspace, and share one with a friend.
+      const ws = await api.newWorkspace({name: 'wsWithSharing'}, 'testy');
+      const shared = await api.newDoc({name: 'shared'}, ws);
+      const unshared = await api.newDoc({name: 'unshared'}, ws);
+      await api.updateDocPermissions(shared, {
+        users: { 'charon@getgrist.com': 'viewers' }
+      });
+
+      // Check the friend sees nothing in their trash.
+      const charon = await home.createHomeApi('charon', 'docs');
+      let result = await charon.forRemoved().getOrgWorkspaces('testy');
+      assert.lengthOf(result, 0);
+
+      // Deleted the unshared doc, and check the friend still sees nothing in their trash.
+      await api.softDeleteDoc(unshared);
+      result = await charon.forRemoved().getOrgWorkspaces('testy');
+      assert.lengthOf(result, 0);
+
+      // Deleted the shared doc, and check the friend sees it in trash.
+      // (There might be a case for only owners seeing it? i.e. you see something in
+      // trash if you have the power to restore it? But in a team site it might be
+      // useful to have some insight into what happened to a doc you can view.)
+      await api.softDeleteDoc(shared);
+      result = await charon.forRemoved().getOrgWorkspaces('testy');
+      assert.deepEqual(docNames(result), ['wsWithSharing:shared']);
+    });
+  });
+});
diff --git a/test/gen-server/lib/scrubUserFromOrg.ts b/test/gen-server/lib/scrubUserFromOrg.ts
new file mode 100644
index 00000000..c670a0f7
--- /dev/null
+++ b/test/gen-server/lib/scrubUserFromOrg.ts
@@ -0,0 +1,453 @@
+import {Role} from 'app/common/roles';
+import {PermissionData} from 'app/common/UserAPI';
+import {assert} from 'chai';
+import {TestServer} from 'test/gen-server/apiUtils';
+import * as testUtils from 'test/server/testUtils';
+
+describe('scrubUserFromOrg', function() {
+
+  let server: TestServer;
+  testUtils.setTmpLogLevel('error');
+
+  beforeEach(async function() {
+    this.timeout(5000);
+    server = new TestServer(this);
+    await server.start();
+    // Use an empty org called "org1" created by "user1" for these tests.
+    const user1 = (await server.dbManager.getUserByLogin('user1@getgrist.com'))!;
+    await server.dbManager.addOrg(user1, {name: 'org1', domain: 'org1'}, {
+      setUserAsOwner: false,
+      useNewPlan: true
+    });
+  });
+
+  afterEach(async function() {
+    await server.stop();
+  });
+
+  // count how many rows there are in the group_users table, for sanity checks.
+  async function countGroupUsers() {
+    return await server.dbManager.connection.manager.count('group_users');
+  }
+
+  // get the home api, making sure the user's api key is set.
+  async function getApi(userName: string, orgName: string) {
+    const user = (await server.dbManager.getUserByLogin(`${userName}@getgrist.com`))!;
+    user.apiKey = `api_key_for_${userName}`;
+    await user.save();
+    return server.createHomeApi(userName, orgName, true);
+  }
+
+  // check what role is listed for the given user in the results of an ACL endpoint.
+  function getRole(access: PermissionData, email: string): string|null|undefined {
+    const row = access.users.find(u => u.email === email);
+    if (!row) { return undefined; }
+    return row.access;
+  }
+
+  // list emails of all users with the given role for the given org.
+  async function listOrg(domain: string, role: Role|null): Promise<string[]> {
+    return (await server.listOrgMembership(domain, role))
+      .map(user => user.logins[0].email);
+  }
+
+  // list emails of all users with the given role for the given workspace, via
+  // directly granted access to the workspace (inherited access not considered).
+  async function listWs(wsId: number, role: Role|null): Promise<string[]> {
+    return (await server.listWorkspaceMembership(wsId, role))
+      .map(user => user.logins[0].email);
+  }
+
+  // list all resources a user has directly been granted access to, as a list
+  // of strings, each of the form "role:resource-name", such as "guests:org1".
+  async function listUser(email: string) {
+    return (await server.listUserMemberships(email))
+      .map(membership => `${membership.role}:${membership.res.name}`).sort();
+  }
+
+  it('can remove users from orgs while preserving doc access', async function() {
+    this.timeout(5000);  // takes about a second locally, so give more time to
+                         // avoid occasional slow runs on jenkins.
+    // In test org "org1", create a test workspace "ws1" and a test document "doc1"
+    const user1 = await getApi('user1', 'org1');
+    const wsId = await user1.newWorkspace({name: 'ws1'}, 'current');
+    const docId = await user1.newDoc({name: 'doc1'}, wsId);
+
+    // Initially the org has only 1 guest - the creator.
+    assert.sameMembers(await listOrg('org1', 'guests'), ['user1@getgrist.com']);
+
+    // Add a set of users to doc1
+    await user1.updateDocPermissions(docId, {
+      maxInheritedRole: 'viewers',
+      users: {
+        'user2@getgrist.com': 'owners',
+        'user3@getgrist.com': 'owners',
+        'user4@getgrist.com': 'editors',
+        'user5@getgrist.com': 'owners',
+      }
+    });
+
+    // Check that the org now has the expected guests.  Even user1, who has
+    // direct access to the org, will be listed as a guest as well.
+    assert.sameMembers(await listOrg('org1', 'guests'),
+                       ['user1@getgrist.com', 'user2@getgrist.com', 'user3@getgrist.com',
+                        'user4@getgrist.com', 'user5@getgrist.com']);
+    // Check the the workspace also has the expected guests.
+    assert.sameMembers(await listWs(wsId, 'guests'),
+                       ['user1@getgrist.com', 'user2@getgrist.com', 'user3@getgrist.com',
+                        'user4@getgrist.com', 'user5@getgrist.com']);
+
+    // Get the home api from user2's perspective (so we can tweak user1's access to doc1).
+    const user2 = await getApi('user2', 'org1');
+
+    // Confirm that user3's maximal role on the org currently is as a guest.
+    let access = await user1.getOrgAccess('current');
+    assert.equal(getRole(access, 'user3@getgrist.com'), 'guests');
+
+    // Check that user1 is an owner on the doc (this happens when the doc's permissions
+    // were updated by user1, since the user changing access must remain an owner).
+    access = await user1.getDocAccess(docId);
+    assert.equal(getRole(access, 'user1@getgrist.com'), 'owners');
+
+    // Lower user1's access to the doc.
+    await user2.updateDocPermissions(docId, {
+      users: { 'user1@getgrist.com': 'viewers' }
+    });
+    access = await user2.getDocAccess(docId);
+    assert.equal(getRole(access, 'user1@getgrist.com'), 'viewers');
+
+    // Have user1 change user3's access to the org.
+    await user1.updateOrgPermissions('current', {
+      users: { 'user3@getgrist.com': 'viewers' }
+    });
+    access = await user2.getDocAccess(docId);
+    assert.equal(getRole(access, 'user1@getgrist.com'), 'viewers');
+    assert.equal(getRole(access, 'user3@getgrist.com'), 'owners');
+
+    // Ok, that has all been preamble.  Now to test user removal.
+    // Have user1 remove user3's access to the org, checking user1+user3's access before and after.
+    let countBefore = await countGroupUsers();
+    assert.sameMembers(await listUser('user3@getgrist.com'),
+                       ['viewers:org1', 'guests:org1', 'guests:ws1', 'owners:Personal', 'owners:doc1']);
+    assert.sameMembers(await listUser('user1@getgrist.com'),
+                       ['owners:org1', 'guests:org1', 'owners:ws1', 'guests:ws1', 'owners:Personal', 'viewers:doc1']);
+    await user1.updateOrgPermissions('current', {
+      users: { 'user3@getgrist.com': null }
+    });
+    let countAfter = await countGroupUsers();
+    // The only resource user3 has access to now is their personal org.
+    assert.sameMembers(await listUser('user3@getgrist.com'), ['owners:Personal']);
+    assert.sameMembers(await listUser('user1@getgrist.com'),
+                       ['owners:org1', 'guests:org1', 'guests:ws1', 'owners:ws1', 'owners:Personal', 'owners:doc1']);
+    assert.sameMembers(await listOrg('org1', 'guests'),
+                       ['user1@getgrist.com', 'user2@getgrist.com',
+                        'user4@getgrist.com', 'user5@getgrist.com']);
+    assert.sameMembers(await listWs(wsId, 'guests'),
+                       ['user1@getgrist.com', 'user2@getgrist.com',
+                        'user4@getgrist.com', 'user5@getgrist.com']);
+    // For overall count of rows in group_users table, here are the changes:
+    //  - Drops: user3 as owner of doc, editor on org, guest on ws and org.
+    //  - Changes: user1 from editor to owner of doc.
+    assert.equal(countAfter, countBefore - 4);
+
+    // Check view API that user3 is removed from the doc, and Owner1 promoted to owner.
+    access = await user2.getDocAccess(docId);
+    assert.equal(getRole(access, 'user3@getgrist.com'), undefined);
+    assert.equal(getRole(access, 'user1@getgrist.com'), 'owners');
+
+    // Lower user1's access to the doc again.
+    await user2.updateDocPermissions(docId, {
+      users: { 'user1@getgrist.com': 'viewers' }
+    });
+    access = await user2.getDocAccess(docId);
+    assert.equal(getRole(access, 'user1@getgrist.com'), 'viewers');
+
+    // Now have user1 remove user4's access to the org.
+    countBefore = await countGroupUsers();
+    await user1.updateOrgPermissions('current', {
+      users: { 'user4@getgrist.com': null }
+    });
+    countAfter = await countGroupUsers();
+
+    // Drops: user4 as editor of doc, guest on ws and org.
+    // Adds: nothing.
+    assert.equal(countAfter, countBefore - 3);
+    assert.sameMembers(await listOrg('org1', 'guests'),
+                       ['user1@getgrist.com', 'user2@getgrist.com', 'user5@getgrist.com']);
+
+    // User4 should be removed from the doc, and user1's access unchanged (since user4 was
+    // not an owner)
+    access = await user2.getDocAccess(docId);
+    assert.equal(getRole(access, 'user4@getgrist.com'), undefined);
+    assert.equal(getRole(access, 'user1@getgrist.com'), 'viewers');
+
+    // Now have a fresh user remove user5's access to the org.
+    await user1.updateOrgPermissions('current', {
+      users: {
+        'user6@getgrist.com': 'owners',
+      }
+    });
+    const user6 = await getApi('user6', 'org1');
+    countBefore = await countGroupUsers();
+    await user6.updateOrgPermissions('current', {
+      users: { 'user5@getgrist.com': null }
+    });
+    countAfter = await countGroupUsers();
+
+    // Drops: user5 as owner of doc, guest on ws and org.
+    // Adds: user6 as owner of doc, guest on ws and org.
+    assert.equal(countAfter, countBefore);
+    assert.sameMembers(await listOrg('org1', 'guests'),
+                       ['user1@getgrist.com', 'user2@getgrist.com', 'user6@getgrist.com']);
+    assert(getRole(await user1.getWorkspaceAccess(wsId), 'user6@getgrist.com'), 'guests');
+  });
+
+  it('can remove users from orgs while preserving workspace access', async function() {
+    this.timeout(5000);  // takes about a second locally, so give more time to
+                         // avoid occasional slow runs on jenkins.
+    // In test org "org1", create a test workspace "ws1"
+    const user1 = await getApi('user1', 'org1');
+    const wsId = await user1.newWorkspace({name: 'ws1'}, 'current');
+
+    // Initially the org has 1 guest - the creator.
+    assert.sameMembers(await listOrg('org1', 'guests'), ['user1@getgrist.com' ]);
+
+    // Add a set of users to ws1
+    await user1.updateWorkspacePermissions(wsId, {
+      maxInheritedRole: 'viewers',
+      users: {
+        'user2@getgrist.com': 'owners',
+        'user3@getgrist.com': 'owners',
+        'user4@getgrist.com': 'editors',
+        'user5@getgrist.com': 'owners',
+      }
+    });
+
+    // Check that the org now has the expected guests.  Even user1, who has
+    // direct access to the org, will be listed as a guest as well.
+    assert.sameMembers(await listOrg('org1', 'guests'),
+                       ['user1@getgrist.com', 'user2@getgrist.com', 'user3@getgrist.com',
+                        'user4@getgrist.com', 'user5@getgrist.com']);
+    // Check the the workspace has no guests.
+    assert.sameMembers(await listWs(wsId, 'guests'), []);
+
+    // Get the home api from user2's perspective (so we can tweak user1's access to ws1).
+    const user2 = await getApi('user2', 'org1');
+
+    // Confirm that user3's maximal role on the org currently is as a guest.
+    let access = await user1.getOrgAccess('current');
+    assert.equal(getRole(access, 'user3@getgrist.com'), 'guests');
+
+    // Check that user1 is an owner on ws1 (this happens when the workspace's permissions
+    // were updated by user1, since the user changing access must remain an owner).
+    access = await user1.getWorkspaceAccess(wsId);
+    assert.equal(getRole(access, 'user1@getgrist.com'), 'owners');
+
+    // Lower user1's access to the workspace.
+    await user2.updateWorkspacePermissions(wsId, {
+      users: { 'user1@getgrist.com': 'viewers' }
+    });
+    access = await user2.getWorkspaceAccess(wsId);
+    assert.equal(getRole(access, 'user1@getgrist.com'), 'viewers');
+
+    // Have user1 change user3's access to the org.
+    await user1.updateOrgPermissions('current', {
+      users: { 'user3@getgrist.com': 'viewers' }
+    });
+    access = await user2.getWorkspaceAccess(wsId);
+    assert.equal(getRole(access, 'user1@getgrist.com'), 'viewers');
+    assert.equal(getRole(access, 'user3@getgrist.com'), 'owners');
+
+    // Ok, that has all been preamble.  Now to test user removal.
+    // Have user1 remove user3's access to the org, checking user1+user3's access before and after.
+    let countBefore = await countGroupUsers();
+    assert.sameMembers(await listUser('user3@getgrist.com'),
+                       ['viewers:org1', 'guests:org1', 'owners:Personal', 'owners:ws1']);
+    assert.sameMembers(await listUser('user1@getgrist.com'),
+                       ['owners:org1', 'guests:org1', 'owners:Personal', 'viewers:ws1']);
+    await user1.updateOrgPermissions('current', {
+      users: { 'user3@getgrist.com': null }
+    });
+    let countAfter = await countGroupUsers();
+    // The only resource user3 has access to now is their personal org.
+    assert.sameMembers(await listUser('user3@getgrist.com'), ['owners:Personal']);
+    assert.sameMembers(await listUser('user1@getgrist.com'),
+                       ['owners:org1', 'guests:org1', 'owners:Personal', 'owners:ws1']);
+    assert.sameMembers(await listOrg('org1', 'guests'),
+                       ['user1@getgrist.com', 'user2@getgrist.com',
+                        'user4@getgrist.com', 'user5@getgrist.com']);
+    assert.sameMembers(await listWs(wsId, 'guests'), []);
+    // For overall count of rows in group_users table, here are the changes:
+    //  - Drops: user3 as owner of ws, editor on org, guest on org.
+    //  - Changes: user1 from editor to owner of ws.
+    assert.equal(countAfter, countBefore - 3);
+
+    // Check view API that user3 is removed from the workspace, and Owner1 promoted to owner.
+    access = await user2.getWorkspaceAccess(wsId);
+    assert.equal(getRole(access, 'user3@getgrist.com'), undefined);
+    assert.equal(getRole(access, 'user1@getgrist.com'), 'owners');
+
+    // Lower user1's access to the workspace again.
+    await user2.updateWorkspacePermissions(wsId, {
+      users: { 'user1@getgrist.com': 'viewers' }
+    });
+    access = await user2.getWorkspaceAccess(wsId);
+    assert.equal(getRole(access, 'user1@getgrist.com'), 'viewers');
+
+    // Now have user1 remove user4's access to the org.
+    countBefore = await countGroupUsers();
+    await user1.updateOrgPermissions('current', {
+      users: { 'user4@getgrist.com': null }
+    });
+    countAfter = await countGroupUsers();
+
+    // Drops: user4 as editor of ws, guest on org.
+    // Adds: nothing.
+    assert.equal(countAfter, countBefore - 2);
+    assert.sameMembers(await listOrg('org1', 'guests'),
+                       ['user1@getgrist.com', 'user2@getgrist.com', 'user5@getgrist.com']);
+
+    // User4 should be removed from the workspace, and user1's access unchanged (since user4 was
+    // not an owner)
+    access = await user2.getWorkspaceAccess(wsId);
+    assert.equal(getRole(access, 'user4@getgrist.com'), undefined);
+    assert.equal(getRole(access, 'user1@getgrist.com'), 'viewers');
+
+    // Now have a fresh user remove user5's access to the org.
+    await user1.updateOrgPermissions('current', {
+      users: {
+        'user6@getgrist.com': 'owners',
+      }
+    });
+    const user6 = await getApi('user6', 'org1');
+    countBefore = await countGroupUsers();
+    await user6.updateOrgPermissions('current', {
+      users: { 'user5@getgrist.com': null }
+    });
+    countAfter = await countGroupUsers();
+
+    // Drops: user5 as owner of workspace, guest on org.
+    // Adds: user6 as owner of workspace, guest on org.
+    assert.equal(countAfter, countBefore);
+    assert.sameMembers(await listOrg('org1', 'guests'),
+                       ['user1@getgrist.com', 'user2@getgrist.com', 'user6@getgrist.com']);
+  });
+
+  it('cannot remove users from orgs without permission', async function() {
+    // In test org "org1", create a test workspace "ws1" and a test document "doc1".
+    const user1 = await getApi('user1', 'org1');
+    const wsId = await user1.newWorkspace({name: 'ws1'}, 'current');
+    const docId = await user1.newDoc({name: 'doc1'}, wsId);
+
+    // Add user2 and user3 as owners of doc1
+    await user1.updateDocPermissions(docId, {
+      users: {
+        'user2@getgrist.com': 'owners',
+        'user3@getgrist.com': 'owners',
+      }
+    });
+
+    // Add user2 and user3 as owners of ws1
+    await user1.updateWorkspacePermissions(wsId, {
+      users: {
+        'user2@getgrist.com': 'owners',
+        'user3@getgrist.com': 'owners',
+      }
+    });
+
+    // Add user2 as member of org, add user3 as editor of org
+    await user1.updateOrgPermissions('current', {
+      users: {
+        'user2@getgrist.com': 'members',
+        'user3@getgrist.com': 'editors',
+      }
+    });
+
+    // user3 should not have the right to remove user2 from org
+    const user3 = await getApi('user3', 'org1');
+    await assert.isRejected(user3.updateOrgPermissions('current', {
+      users: { 'user2@getgrist.com': null }
+    }));
+
+    // user2 should not have the right to remove user3 from org
+    const user2 = await getApi('user2', 'org1');
+    await assert.isRejected(user2.updateOrgPermissions('current', {
+      users: { 'user3@getgrist.com': null }
+    }));
+
+    // user2 and user3 should still have same access as before
+    assert.sameMembers(await listUser('user2@getgrist.com'),
+                       ['owners:Personal', 'members:org1', 'owners:ws1', 'owners:doc1',
+                        'guests:org1', 'guests:ws1']);
+    assert.sameMembers(await listUser('user3@getgrist.com'),
+                       ['owners:Personal', 'editors:org1', 'owners:ws1', 'owners:doc1',
+                        'guests:org1', 'guests:ws1']);
+  });
+
+  it('does not scrub user for removal from workspace or doc', async function() {
+    // In test org "org1", create a test workspace "ws1" and a test document "doc1".
+    const user1 = await getApi('user1', 'org1');
+    const wsId = await user1.newWorkspace({name: 'ws1'}, 'current');
+    const docId = await user1.newDoc({name: 'doc1'}, wsId);
+
+    // Add user2 and user3 as owners of doc1
+    await user1.updateDocPermissions(docId, {
+      users: {
+        'user2@getgrist.com': 'owners',
+        'user3@getgrist.com': 'owners',
+      }
+    });
+
+    // Add user2 and user3 as owners of ws1
+    await user1.updateWorkspacePermissions(wsId, {
+      users: {
+        'user2@getgrist.com': 'owners',
+        'user3@getgrist.com': 'owners',
+      }
+    });
+
+    // Add user2 as member of org, add user3 as editor of org
+    await user1.updateOrgPermissions('current', {
+      users: {
+        'user2@getgrist.com': 'members',
+        'user3@getgrist.com': 'editors',
+      }
+    });
+
+    // user3 can removed user2 from workspace
+    const user3 = await getApi('user3', 'org1');
+    await user3.updateWorkspacePermissions(wsId, {
+      users: { 'user2@getgrist.com': null }
+    });
+
+    // user3's access should be unchanged
+    assert.sameMembers(await listUser('user3@getgrist.com'),
+                       ['owners:Personal', 'editors:org1', 'owners:ws1', 'owners:doc1',
+                        'guests:org1', 'guests:ws1']);
+    // user2's access should be changed just as requested
+    assert.sameMembers(await listUser('user2@getgrist.com'),
+                       ['owners:Personal', 'members:org1', 'owners:doc1',
+                        'guests:org1', 'guests:ws1']);
+
+    // put user2 back in workspace
+    await user3.updateWorkspacePermissions(wsId, {
+      users: { 'user2@getgrist.com': 'owners' }
+    });
+    assert.sameMembers(await listUser('user2@getgrist.com'),
+                       ['owners:Personal', 'members:org1', 'owners:ws1', 'owners:doc1',
+                        'guests:org1', 'guests:ws1']);
+
+    // user3 can removed user2 from doc
+    await user3.updateDocPermissions(docId, {
+      users: { 'user2@getgrist.com': null }
+    });
+
+    // user3's access should be unchanged
+    assert.sameMembers(await listUser('user3@getgrist.com'),
+                       ['owners:Personal', 'editors:org1', 'owners:ws1', 'owners:doc1',
+                        'guests:org1', 'guests:ws1']);
+    // user2's access should be changed just as requested
+    assert.sameMembers(await listUser('user2@getgrist.com'),
+                       ['owners:Personal', 'members:org1', 'owners:ws1', 'guests:org1']);
+  });
+});
diff --git a/test/gen-server/lib/suspension.ts b/test/gen-server/lib/suspension.ts
new file mode 100644
index 00000000..3d2465c3
--- /dev/null
+++ b/test/gen-server/lib/suspension.ts
@@ -0,0 +1,55 @@
+import {Organization} from 'app/common/UserAPI';
+import {assert} from 'chai';
+import {TestServer} from 'test/gen-server/apiUtils';
+import {setPlan} from 'test/gen-server/testUtils';
+import {createTmpDir} from 'test/server/docTools';
+import * as testUtils from 'test/server/testUtils';
+
+describe('suspension', function() {
+  let home: TestServer;
+  let nasa: Organization;
+  testUtils.setTmpLogLevel('error');
+
+  before(async function() {
+    const tmpDir = await createTmpDir();
+    home = new TestServer(this);
+    await home.start(["home", "docs"], {dataDir: tmpDir});
+    const nasaApi = await home.createHomeApi('Chimpy', 'nasa');
+    nasa = await nasaApi.getOrg('current');
+  });
+
+  after(async function() {
+    await setPlan(home.dbManager, nasa, nasa.billingAccount!.product.name);
+    await home.stop();
+  });
+
+  it('limits user to read-only access', async function() {
+    this.timeout(4000);
+
+    // Open nasa as chimpy (an owner)
+    const nasaApi = await home.createHomeApi('Chimpy', 'nasa');
+    // Set up Jupiter document to have some content
+    const docId = await home.dbManager.testGetId('Jupiter') as string;
+    await home.copyFixtureDoc('Hello.grist', docId);
+    assert((await nasaApi.getDoc(docId)).access, 'owners');
+
+    // Confirm that user can edit docs
+    const docApi = nasaApi.getDocAPI(docId);
+    await assert.isFulfilled(docApi.getRows('Table1'));
+    await assert.isFulfilled(docApi.updateRows('Table1', { id: [1], A: ['v1'] }));
+    await assert.isFulfilled(docApi.addRows('Table1', { A: ['v1'] }));
+
+    // Now suspend org
+    await setPlan(home.dbManager, nasa, 'suspended');
+
+    // User should no longer be able to edit, but can view and download
+    // Note a bit of cheating here: the call to getDoc() invalidates docAuthCache; without it, it
+    // would be a few seconds before the change in access level is visible.
+    assert((await nasaApi.getDoc(docId)).access, 'viewers');
+    await assert.isFulfilled(docApi.getRows('Table1'));
+    await assert.isRejected(docApi.updateRows('Table1', { id: [1], A: ['v1'] }), /No write access/);
+    await assert.isRejected(docApi.addRows('Table1', { A: ['v1'] }), /No write access/);
+    const worker = await nasaApi.getWorkerAPI(docId);
+    assert(await worker.downloadDoc(docId));  // download still works
+  });
+});
diff --git a/test/gen-server/lib/urlIds.ts b/test/gen-server/lib/urlIds.ts
new file mode 100644
index 00000000..d15fa46f
--- /dev/null
+++ b/test/gen-server/lib/urlIds.ts
@@ -0,0 +1,131 @@
+import {UserAPI} from 'app/common/UserAPI';
+import {Document} from 'app/gen-server/entity/Document';
+import {assert} from 'chai';
+import {TestServer} from 'test/gen-server/apiUtils';
+import * as testUtils from 'test/server/testUtils';
+
+describe('urlIds', function() {
+  let home: TestServer;
+  let supportWorkspaceId: number;
+  testUtils.setTmpLogLevel('error');
+
+  before(async function() {
+    home = new TestServer(this);
+    await home.start(["home", "docs"]);
+    const api = await home.createHomeApi('chimpy', 'nasa');
+    await api.updateOrgPermissions('current', {users: {
+      'testuser1@getgrist.com': 'owners',
+      'testuser2@getgrist.com': 'owners',
+    }});
+
+    // Share a workspace in support's personal org with everyone
+    const support = await home.newSession().createHomeApi('Support', 'docs');
+    await home.upgradePersonalOrg('Support');
+    supportWorkspaceId = await support.newWorkspace({name: 'Examples & Templates'}, 'current');
+    await support.newDoc({name: 'an example', urlId: 'example'}, supportWorkspaceId);
+    await support.updateWorkspacePermissions(supportWorkspaceId, {
+      users: {'everyone@getgrist.com': 'viewers',
+              'anon@getgrist.com': 'viewers'}
+    });
+    // Update special workspace informationn
+    await home.dbManager.initializeSpecialIds();
+  });
+
+  after(async function() {
+    // Undo test-specific configuration
+    const api = await home.createHomeApi('chimpy', 'nasa');
+    await api.updateOrgPermissions('current', {users: {
+      'testuser1@getgrist.com': null,
+      'testuser2@getgrist.com': null,
+    }});
+    const support = await home.newSession().createHomeApi('Support', 'docs');
+    await support.deleteWorkspace(supportWorkspaceId);
+    await home.dbManager.initializeSpecialIds();
+
+    await home.stop();
+  });
+
+  for (const org of ['docs', 'nasa']) {
+    it(`cannot set two docs to the same urlId in ${org}`, async function() {
+      const api1 = await home.newSession().createHomeApi('testuser1', org);
+      const api2 = await home.newSession().createHomeApi('testuser2', org);
+      const ws1 = await getAnyWorkspace(api1);
+      const ws2 = await getAnyWorkspace(api2);
+      const doc1 = await api1.newDoc({name: 'testdoc1', urlId: 'urlid-common'}, ws1);
+      await assert.isRejected(api2.newDoc({name: 'testdoc2', urlId: 'urlid-common'}, ws2),
+                              /urlId already in use/);
+      assert((await api1.getDoc('urlid-common')).id, doc1);
+      assert((await api1.getDoc('urlid-common')).urlId, 'urlid-common');
+      await api1.deleteDoc(doc1);
+    });
+
+    it(`can set two docs to different urlIds in ${org}`, async function() {
+      const api1 = await home.newSession().createHomeApi('testuser1', org);
+      const api2 = await home.newSession().createHomeApi('testuser2', org);
+      const ws1 = await getAnyWorkspace(api1);
+      const ws2 = await getAnyWorkspace(api2);
+      const doc1 = await api1.newDoc({name: 'testdoc1', urlId: 'urlid1'}, ws1);
+      const doc2 = await api2.newDoc({name: 'testdoc2', urlId: 'urlid2'}, ws2);
+      assert((await api1.getDoc('urlid1')).id, doc1);
+      assert((await api1.getDoc('urlid1')).urlId, 'urlid1');
+      assert((await api2.getDoc('urlid2')).id, doc2);
+      assert((await api2.getDoc('urlid2')).urlId, 'urlid2');
+    });
+
+    it(`cannot reuse example urlIds in ${org}`, async function() {
+      const api1 = await home.newSession().createHomeApi('testuser1', org);
+      const ws1 = await getAnyWorkspace(api1);
+      await assert.isRejected(api1.newDoc({name: 'my example', urlId: 'example'}, ws1),
+                              /urlId already in use/);
+    });
+
+    it(`cannot use an existing docId as a urlId in ${org}`, async function() {
+      const doc = await home.dbManager.connection.manager.findOneOrFail(Document, {where: {}});
+      const prevDocId = doc.id;
+      try {
+        // Change doc id to ensure it has characters permitted for a urlId.
+        // Not all docIds are like that (test doc ids have underscores; current
+        // style doc ids typically have capital letters in them).
+        doc.id = 'doc-id';
+        await doc.save();
+        const api1 = await home.newSession().createHomeApi('testuser1', org);
+        const ws1 = await getAnyWorkspace(api1);
+        await assert.isRejected(api1.newDoc({name: 'my example', urlId: doc.id}, ws1),
+                                /urlId already in use as document id/);
+      } finally {
+        doc.id = prevDocId;
+        await doc.save();
+      }
+    });
+
+    it(`cannot reuse urlIds from ${org} in examples`, async function() {
+      const api1 = await home.newSession().createHomeApi('testuser1', org);
+      const ws1 = await getAnyWorkspace(api1);
+      await api1.newDoc({name: 'my example', urlId: `urlid-${org}`}, ws1);
+      const support = await home.newSession().createHomeApi('Support', 'docs');
+      await assert.isRejected(support.newDoc({name: 'my conflicting example',
+                                              urlId: `urlid-${org}`}, supportWorkspaceId),
+                              /urlId already in use/);
+    });
+  }
+
+  it(`correctly uses org information for urlId disambiguation`, async function() {
+    const api1 = await home.newSession().createHomeApi('testuser1', 'docs');
+    const api2 = await home.newSession().createHomeApi('testuser2', 'nasa');
+    const ws1 = await getAnyWorkspace(api1);
+    const ws2 = await getAnyWorkspace(api2);
+    const doc1 = await api1.newDoc({name: 'testdoc1', urlId: 'urlid-common'}, ws1);
+    const doc2 = await api2.newDoc({name: 'testdoc2', urlId: 'urlid-common'}, ws2);
+    assert.equal((await api1.getDoc('urlid-common')).id, doc1);
+    assert.equal((await api2.getDoc('urlid-common')).id, doc2);
+    await api1.updateDoc('urlid-common', {name: 'testdoc1-updated'});
+    await api2.updateDoc('urlid-common', {name: 'testdoc2-updated'});
+    assert.equal((await api1.getDoc('urlid-common')).name, 'testdoc1-updated');
+    assert.equal((await api2.getDoc('urlid-common')).name, 'testdoc2-updated');
+  });
+
+  async function getAnyWorkspace(api: UserAPI) {
+    const workspaces = await api.getOrgWorkspaces('current');
+    return workspaces[0]!.id;
+  }
+});

From ef4180c8da8e21835760917f37acea0be3a6b326 Mon Sep 17 00:00:00 2001
From: Dmitry S <dsagal+git@gmail.com>
Date: Thu, 15 Aug 2024 13:50:32 -0400
Subject: [PATCH 133/145] (core) Fix unhandledRejection caused by exception
 from verifyClient.

Summary:
This includes two fixes: one to ensure that any exception from websocket
upgrade handlers are handled (by destroying the socket). A test case is
added for this.

The other is to ensure verifyClient returns false instead of failing; this
should lead to a better error to the client (Forbidden, rather than just socket
close). This is only tested manually with a curl request.

Test Plan: Added a test case for the more sensitive half of the fix.

Reviewers: georgegevoian

Reviewed By: georgegevoian

Subscribers: georgegevoian

Differential Revision: https://phab.getgrist.com/D4323
---
 app/server/lib/Comm.ts              | 23 +++++++++++++--------
 app/server/lib/GristSocketServer.ts | 19 +++++++++++++----
 test/server/lib/GristSockets.ts     | 32 +++++++++++++++++++++++++----
 3 files changed, 58 insertions(+), 16 deletions(-)

diff --git a/app/server/lib/Comm.ts b/app/server/lib/Comm.ts
index eb13c632..b414661a 100644
--- a/app/server/lib/Comm.ts
+++ b/app/server/lib/Comm.ts
@@ -247,15 +247,22 @@ export class Comm extends EventEmitter {
     for (const server of servers) {
       const wssi = new GristSocketServer(server, {
         verifyClient: async (req: http.IncomingMessage) => {
-          if (this._options.hosts) {
-            // DocWorker ID (/dw/) and version tag (/v/) may be present in this request but are not
-            // needed. addOrgInfo assumes req.url starts with /o/ if present.
-            req.url = parseFirstUrlPart('dw', req.url!).path;
-            req.url = parseFirstUrlPart('v', req.url).path;
-            await this._options.hosts.addOrgInfo(req);
-          }
+          try {
+            if (this._options.hosts) {
+              // DocWorker ID (/dw/) and version tag (/v/) may be present in this request but are not
+              // needed. addOrgInfo assumes req.url starts with /o/ if present.
+              req.url = parseFirstUrlPart('dw', req.url!).path;
+              req.url = parseFirstUrlPart('v', req.url).path;
+              await this._options.hosts.addOrgInfo(req);
+            }
 
-          return trustOrigin(req);
+            return trustOrigin(req);
+          } catch (err) {
+            // Consider exceptions (e.g. in parsing unexpected hostname) as failures to verify.
+            // In practice, we only see this happening for spammy/illegitimate traffic; there is
+            // no particular reason to log these.
+            return false;
+          }
         }
       });
 
diff --git a/app/server/lib/GristSocketServer.ts b/app/server/lib/GristSocketServer.ts
index 5a098f06..b39fc274 100644
--- a/app/server/lib/GristSocketServer.ts
+++ b/app/server/lib/GristSocketServer.ts
@@ -3,10 +3,13 @@ import * as WS from 'ws';
 import * as EIO from 'engine.io';
 import {GristServerSocket, GristServerSocketEIO, GristServerSocketWS} from './GristServerSocket';
 import * as net from 'net';
+import * as stream from 'stream';
 
 const MAX_PAYLOAD = 100e6;
 
 export interface GristSocketServerOptions {
+  // Check if this request should be accepted. To produce a valid response (perhaps a rejection),
+  // this callback should not throw.
   verifyClient?: (request: http.IncomingMessage) => Promise<boolean>;
 }
 
@@ -64,7 +67,15 @@ export class GristSocketServer {
 
   private _attach(server: http.Server) {
     // Forward all WebSocket upgrade requests to WS
-    server.on('upgrade', async (request, socket, head) => {
+
+    // Wrapper for server event handlers that catches rejected promises, which would otherwise
+    // lead to "unhandledRejection" and process exit. Instead we abort the connection, which helps
+    // in testing this scenario. This is a fallback; in reality, handlers should never throw.
+    function destroyOnRejection(socket: stream.Duplex, func: () => Promise<void>) {
+      func().catch(e => { socket.destroy(); });
+    }
+
+    server.on('upgrade', (request, socket, head) => destroyOnRejection(socket, async () => {
       if (this._options?.verifyClient && !await this._options.verifyClient(request)) {
         // Because we are handling an "upgrade" event, we don't have access to
         // a "response" object, just the raw socket. We can still construct
@@ -76,14 +87,14 @@ export class GristSocketServer {
       this._wsServer.handleUpgrade(request, socket as net.Socket, head, (client) => {
         this._connectionHandler?.(new GristServerSocketWS(client), request);
       });
-    });
+    }));
 
     // At this point an Express app is installed as the handler for the server's
     // "request" event. We need to install our own listener instead, to intercept
     // requests that are meant for the Engine.IO polling implementation.
     const listeners = [...server.listeners("request")];
     server.removeAllListeners("request");
-    server.on("request", async (req, res) => {
+    server.on("request", (req, res) => destroyOnRejection(req.socket, async() => {
       // Intercept requests that have transport=polling in their querystring
       if (/[&?]transport=polling(&|$)/.test(req.url ?? '')) {
         if (this._options?.verifyClient && !await this._options.verifyClient(req)) {
@@ -98,7 +109,7 @@ export class GristSocketServer {
           listener.call(server, req, res);
         }
       }
-    });
+    }));
 
     server.on("close", this.close.bind(this));
   }
diff --git a/test/server/lib/GristSockets.ts b/test/server/lib/GristSockets.ts
index f08b8214..f1e8725d 100644
--- a/test/server/lib/GristSockets.ts
+++ b/test/server/lib/GristSockets.ts
@@ -1,7 +1,7 @@
 import { assert } from 'chai';
 import * as http from 'http';
 import { GristClientSocket } from 'app/client/components/GristClientSocket';
-import { GristSocketServer } from 'app/server/lib/GristSocketServer';
+import { GristSocketServer, GristSocketServerOptions } from 'app/server/lib/GristSocketServer';
 import { fromCallback, listenPromise } from 'app/server/lib/serverUtils';
 import { AddressInfo } from 'net';
 import httpProxy from 'http-proxy';
@@ -29,9 +29,9 @@ describe(`GristSockets`, function () {
         await stopSocketServer();
       });
 
-      async function startSocketServer() {
+      async function startSocketServer(options?: GristSocketServerOptions) {
         server = http.createServer((req, res) => res.writeHead(404).end());
-        socketServer = new GristSocketServer(server);
+        socketServer = new GristSocketServer(server, options);
         await listenPromise(server.listen(0, 'localhost'));
         serverPort = (server.address() as AddressInfo).port;
       }
@@ -165,6 +165,30 @@ describe(`GristSockets`, function () {
         await closePromise;
       });
 
+      it("should fail gracefully if verifyClient throws exception", async function () {
+
+        // Restart servers with a failing verifyClient method.
+        await stopProxyServer();
+        await stopSocketServer();
+        await startSocketServer({verifyClient: () => { throw new Error("Test error from verifyClient"); }});
+        await startProxyServer();
+
+        // Check whether we are getting an unhandledRejection.
+        let rejection: unknown = null;
+        const onUnhandledRejection = (err: unknown) => { rejection = err; };
+        process.on('unhandledRejection', onUnhandledRejection);
+
+        try {
+          // The "poll error" comes from the fallback to polling.
+          await assert.isRejected(connectClient(wsAddress), /poll error/);
+        } finally {
+          // Typings for process.removeListener are broken, possibly by electron's presence
+          // (https://github.com/electron/electron/issues/9626).
+          process.removeListener('unhandledRejection' as any, onUnhandledRejection);
+        }
+        // The important thing is that we don't get unhandledRejection.
+        assert.equal(rejection, null);
+      });
     });
   }
-});
\ No newline at end of file
+});

From 1c77909cda9e133359354cf5d17c03269d9170d4 Mon Sep 17 00:00:00 2001
From: xabirequejo <xabi.rn@gmail.com>
Date: Thu, 15 Aug 2024 15:39:28 +0000
Subject: [PATCH 134/145] Translated using Weblate (Basque)

Currently translated at 99.8% (1376 of 1378 strings)

Translation: Grist/client
Translate-URL: https://hosted.weblate.org/projects/grist/client/eu/
---
 static/locales/eu.client.json | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/static/locales/eu.client.json b/static/locales/eu.client.json
index cb51405f..ecb6a0e0 100644
--- a/static/locales/eu.client.json
+++ b/static/locales/eu.client.json
@@ -1273,7 +1273,7 @@
     },
     "FieldEditor": {
         "It should be impossible to save a plain data value into a formula column": "Ezinezkoa litzateke datu-balio soil bat formula-zutabe batean gordetzea",
-        "Unable to finish saving edited cell": "Ezin izan da gelaxka gordetzen amaitu"
+        "Unable to finish saving edited cell": "Ezin da gelaxka gordetzen amaitu"
     },
     "HyperLinkEditor": {
         "[link label] url": "[link label] URLa"

From 813e5cc26f364cde925056249f141f9617180dad Mon Sep 17 00:00:00 2001
From: SALIH AYDIN <salih26@gmail.com>
Date: Sat, 17 Aug 2024 07:33:53 +0200
Subject: [PATCH 135/145] Added translation using Weblate (Turkish)

---
 static/locales/tr.client.json | 1 +
 1 file changed, 1 insertion(+)
 create mode 100644 static/locales/tr.client.json

diff --git a/static/locales/tr.client.json b/static/locales/tr.client.json
new file mode 100644
index 00000000..0967ef42
--- /dev/null
+++ b/static/locales/tr.client.json
@@ -0,0 +1 @@
+{}

From d753d5e7ae848a121eda9f27d7ed97c7ad157fc0 Mon Sep 17 00:00:00 2001
From: SALIH AYDIN <salih26@gmail.com>
Date: Sat, 17 Aug 2024 05:41:08 +0000
Subject: [PATCH 136/145] Translated using Weblate (Turkish)

Currently translated at 0.4% (6 of 1378 strings)

Translation: Grist/client
Translate-URL: https://hosted.weblate.org/projects/grist/client/tr/
---
 static/locales/tr.client.json | 13 ++++++++++++-
 1 file changed, 12 insertions(+), 1 deletion(-)

diff --git a/static/locales/tr.client.json b/static/locales/tr.client.json
index 0967ef42..bc5ea6d4 100644
--- a/static/locales/tr.client.json
+++ b/static/locales/tr.client.json
@@ -1 +1,12 @@
-{}
+{
+    "ACUserManager": {
+        "Invite new member": "Yeni Üye Davet gönder",
+        "Enter email address": "mail adresinizi girin",
+        "We'll email an invite to {{email}}": "{{email}} .. adresine davet gönderilecek"
+    },
+    "AccessRules": {
+        "Add Column Rule": "Sütuna Kural ekle",
+        "Add Default Rule": "Kural ekle (genel)",
+        "Add Table Rules": "Tabloya kural ekle"
+    }
+}

From 3c45d8e43b21152d651830ac90f707862f4ea0f5 Mon Sep 17 00:00:00 2001
From: Riccardo Polignieri <ric.pol@libero.it>
Date: Sun, 18 Aug 2024 09:09:45 +0000
Subject: [PATCH 137/145] Translated using Weblate (Italian)

Currently translated at 100.0% (1378 of 1378 strings)

Translation: Grist/client
Translate-URL: https://hosted.weblate.org/projects/grist/client/it/
---
 static/locales/it.client.json | 55 +++++++++++++++++++++++++++++++++--
 1 file changed, 53 insertions(+), 2 deletions(-)

diff --git a/static/locales/it.client.json b/static/locales/it.client.json
index 4c46628b..f3d3f02f 100644
--- a/static/locales/it.client.json
+++ b/static/locales/it.client.json
@@ -1237,7 +1237,8 @@
         "Sorry, not all fields can be edited.": "Spiacente, non tutti i campi possono essere modificati.",
         "Status": "Status",
         "URL": "URL",
-        "Filter for changes in these columns (semicolon-separated ids)": "FIltrare i cambiamenti in queste colonne (id separati da ;)"
+        "Filter for changes in these columns (semicolon-separated ids)": "FIltrare i cambiamenti in queste colonne (id separati da ;)",
+        "Header Authorization": "Header autorizzazione"
     },
     "Clipboard": {
         "Unavailable Command": "Comando non disponibile",
@@ -1498,7 +1499,12 @@
         "Authentication": "Autenticazione",
         "Check failed.": "Controllo fallito.",
         "Check succeeded.": "Controllo riuscito.",
-        "Grist allows different types of authentication to be configured, including SAML and OIDC.     We recommend enabling one of these if Grist is accessible over the network or being made available     to multiple people.": "Grist consente di configurare diversi tipi di autenticazione, compresi SAML e OIDC.     Raccomandiamo di attivarne uno, se Grist è dispobile in rete, o raggiungibile      da più persone."
+        "Grist allows different types of authentication to be configured, including SAML and OIDC.     We recommend enabling one of these if Grist is accessible over the network or being made available     to multiple people.": "Grist consente di configurare diversi tipi di autenticazione, compresi SAML e OIDC.     Raccomandiamo di attivarne uno, se Grist è dispobile in rete, o raggiungibile      da più persone.",
+        "Session Secret": "Segreto per la sessione",
+        "Grist signs user session cookies with a secret key. Please set this key via the environment variable GRIST_SESSION_SECRET. Grist falls back to a hard-coded default when it is not set. We may remove this notice in the future as session IDs generated since v1.1.16 are inherently cryptographically secure.": "Grist firma i cookie della sessione con una chiave segreta. Impostare questa chiave con la variabile d'ambiente GRIST_SESSION_SECRET. Se questa non è definita, Grist usa un default non modificabile. Potremmo rimuovere questo avviso in futuro, perché gli ID di sessione generati a partire dalla versione 1.1.16 sono intrinsecamente sicuri dal punto di vista crittografico.",
+        "Enable Grist Enterprise": "Attiva Grist Enterprise",
+        "Enterprise": "Enterprise",
+        "Key to sign sessions with": "Chiave per marcare le sessioni"
     },
     "WelcomeCoachingCall": {
         "Maybe Later": "Forse più tardi",
@@ -1629,5 +1635,50 @@
         "Table ID": "ID Tabella",
         "Column ID": "ID colonna",
         "Formula timer": "Cronometro formule"
+    },
+    "ToggleEnterpriseWidget": {
+        "An activation key is used to run Grist Enterprise after a trial period\nof 30 days has expired. Get an activation key by [signing up for Grist\nEnterprise]({{signupLink}}). You do not need an activation key to run\nGrist Core.\n\nLearn more in our [Help Center]({{helpCenter}}).": "La chiave di attivazione serve a usare Grist Enterprise dopo la fine\ndei 30 giorni di prova. Ottieni la chiave di attivazione [iscrivendoti a Grist\nEnterprise]({{signupLink}}). Non c'è bisogno di una chiave di attivazione per\nGrist Core.\n\nScopri di più nel nostro [Centro Assistenza]({{helpCenter}}).",
+        "Disable Grist Enterprise": "Disattiva Grist Enterprise",
+        "Enable Grist Enterprise": "Attiva Grist Enterprise",
+        "Grist Enterprise is **enabled**.": "Grist Enterprise è **attivato**."
+    },
+    "DocTutorial": {
+        "Finish": "Finisci",
+        "Previous": "Precedente",
+        "Next": "Successivo",
+        "Restart": "Riparti",
+        "Click to expand": "Clicca per espandere",
+        "Do you want to restart the tutorial? All progress will be lost.": "Vuoi ricominciare il tutorial? Tutti i progressi fatti saranno azzerati.",
+        "End tutorial": "Termina il tutorial"
+    },
+    "OnboardingCards": {
+        "3 minute video tour": "Video introduttivo di tre minuti",
+        "Complete our basics tutorial": "Completa il nostro tutorial di base",
+        "Complete the tutorial": "Completa il tutorial",
+        "Learn the basic of reference columns, linked widgets, column types, & cards.": "Impara le basi sulle colonne di riferimenti, i widget collegati, i tipi di colonna e le schede."
+    },
+    "OnboardingPage": {
+        "Welcome": "Benvenuto",
+        "What brings you to Grist (you can select multiple)?": "Che cosa ti ha portato a Grist (puoi scegliere più opzioni)?",
+        "Go to the tutorial!": "Vai al tutorial!",
+        "Next step": "Passo successivo",
+        "Type here": "Scrivi qui",
+        "Tell us who you are": "Dicci chi sei",
+        "What organization are you with?": "Di che organizzazione fai parte?",
+        "Back": "Indietro",
+        "Discover Grist in 3 minutes": "Scopri Grist in tre minuti",
+        "Go hands-on with the Grist Basics tutorial": "Inizia a lavorare con il tutorial introduttivo di Grist",
+        "Skip step": "Salta questo passaggio",
+        "Skip tutorial": "Salta il tutorial",
+        "What is your role?": "Qual è il tuo ruolo?",
+        "Your organization": "La tua organizzazione",
+        "Your role": "Il tuo ruolo"
+    },
+    "ViewLayout": {
+        "Delete": "Cancella",
+        "Delete data and this widget.": "Cancella i dati e questo widget.",
+        "Keep data and delete widget. Table will remain available in {{rawDataLink}}": "Mantieni i dati e cancella il widget. La tabella resterà disponibile in {{rawDataLink}}",
+        "Table {{tableName}} will no longer be visible": "La tabella {{tableName}} non sarà più visibile",
+        "raw data page": "pagina dei dati grezzi"
     }
 }

From 9ff8893e416810075b947407cbe38c1dbafed39d Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
 <41898282+github-actions[bot]@users.noreply.github.com>
Date: Mon, 19 Aug 2024 08:08:51 -0400
Subject: [PATCH 138/145] automated update to translation keys (#1150)

Co-authored-by: Paul's Grist Bot <paul+bot@getgrist.com>
---
 static/locales/en.client.json | 59 ++++++++++++++++++++++++++++++++---
 1 file changed, 55 insertions(+), 4 deletions(-)

diff --git a/static/locales/en.client.json b/static/locales/en.client.json
index 4b88c5e1..889fbf18 100644
--- a/static/locales/en.client.json
+++ b/static/locales/en.client.json
@@ -204,7 +204,15 @@
         "{{wrongTypeCount}} non-{{columnType}} columns are not shown_one": "{{wrongTypeCount}} non-{{columnType}} column is not shown",
         "{{wrongTypeCount}} non-{{columnType}} columns are not shown_other": "{{wrongTypeCount}} non-{{columnType}} columns are not shown",
         "Clear selection": "Clear selection",
-        "No {{columnType}} columns in table.": "No {{columnType}} columns in table."
+        "No {{columnType}} columns in table.": "No {{columnType}} columns in table.",
+        "ACCESS LEVEL": "ACCESS LEVEL",
+        "Accept": "Accept",
+        "Custom URL": "Custom URL",
+        "Developer:": "Developer:",
+        "Last updated:": "Last updated:",
+        "Missing description and author information.": "Missing description and author information.",
+        "Reject": "Reject",
+        "Widget": "Widget"
     },
     "DataTables": {
         "Click to copy": "Click to copy",
@@ -931,7 +939,9 @@
         "An unknown error occurred.": "An unknown error occurred.",
         "Build your own form": "Build your own form",
         "Form not found": "Form not found",
-        "Powered by": "Powered by"
+        "Powered by": "Powered by",
+        "Failed to log in.{{separator}}Please try again or contact support.": "Failed to log in.{{separator}}Please try again or contact support.",
+        "Sign-in failed{{suffix}}": "Sign-in failed{{suffix}}"
     },
     "menus": {
         "* Workspaces are available on team plans. ": "* Workspaces are available on team plans. ",
@@ -1193,7 +1203,8 @@
         "Learn more": "Learn more",
         "These rules are applied after all column rules have been processed, if applicable.": "These rules are applied after all column rules have been processed, if applicable.",
         "Example: {{example}}": "Example: {{example}}",
-        "Filter displayed dropdown values with a condition.": "Filter displayed dropdown values with a condition."
+        "Filter displayed dropdown values with a condition.": "Filter displayed dropdown values with a condition.",
+        "Community widgets are created and maintained by Grist community members.": "Community widgets are created and maintained by Grist community members."
     },
     "DescriptionConfig": {
         "DESCRIPTION": "DESCRIPTION"
@@ -1649,7 +1660,8 @@
         "3 minute video tour": "3 minute video tour",
         "Complete our basics tutorial": "Complete our basics tutorial",
         "Complete the tutorial": "Complete the tutorial",
-        "Learn the basic of reference columns, linked widgets, column types, & cards.": "Learn the basic of reference columns, linked widgets, column types, & cards."
+        "Learn the basic of reference columns, linked widgets, column types, & cards.": "Learn the basic of reference columns, linked widgets, column types, & cards.",
+        "Learn the basics of reference columns, linked widgets, column types, & cards.": "Learn the basics of reference columns, linked widgets, column types, & cards."
     },
     "OnboardingPage": {
         "Back": "Back",
@@ -1680,5 +1692,44 @@
         "Keep data and delete widget. Table will remain available in {{rawDataLink}}": "Keep data and delete widget. Table will remain available in {{rawDataLink}}",
         "Table {{tableName}} will no longer be visible": "Table {{tableName}} will no longer be visible",
         "raw data page": "raw data page"
+    },
+    "AdminPanelName": {
+        "Admin Panel": "Admin Panel"
+    },
+    "CustomWidgetGallery": {
+        "(Missing info)": "(Missing info)",
+        "Add Widget": "Add Widget",
+        "Add Your Own Widget": "Add Your Own Widget",
+        "Add a widget from outside this gallery.": "Add a widget from outside this gallery.",
+        "Cancel": "Cancel",
+        "Change Widget": "Change Widget",
+        "Choose Custom Widget": "Choose Custom Widget",
+        "Community Widget": "Community Widget",
+        "Custom URL": "Custom URL",
+        "Developer:": "Developer:",
+        "Grist Widget": "Grist Widget",
+        "Last updated:": "Last updated:",
+        "Learn more about Custom Widgets": "Learn more about Custom Widgets",
+        "No matching widgets": "No matching widgets",
+        "Search": "Search",
+        "Widget URL": "Widget URL"
+    },
+    "markdown": {
+        "# New Markdown Function\n *\n *      We can _write_ [the usual Markdown](https:": {
+            "": {
+                "markdownguide.org) *inside*\n *      a Grainjs element.": "# New Markdown Function\n *\n *      We can _write_ [the usual Markdown](https://markdownguide.org) *inside*\n *      a Grainjs element."
+            }
+        },
+        "The toggle is **off**": "The toggle is **off**",
+        "The toggle is **on**": "The toggle is **on**"
+    },
+    "markdown.d": {
+        "# New Markdown Function\n *\n *      We can _write_ [the usual Markdown](https:": {
+            "": {
+                "markdownguide.org) *inside*\n *      a Grainjs element.": "# New Markdown Function\n *\n *      We can _write_ [the usual Markdown](https://markdownguide.org) *inside*\n *      a Grainjs element."
+            }
+        },
+        "The toggle is **off**": "The toggle is **off**",
+        "The toggle is **on**": "The toggle is **on**"
     }
 }

From 53bc030d1b29edbee86cacae7801447437341ff9 Mon Sep 17 00:00:00 2001
From: Xavi Montero <xmontero@dsitelecom.com>
Date: Mon, 19 Aug 2024 19:12:05 +0200
Subject: [PATCH 139/145] Added translation using Weblate (Catalan)

---
 static/locales/ca.client.json | 1 +
 1 file changed, 1 insertion(+)
 create mode 100644 static/locales/ca.client.json

diff --git a/static/locales/ca.client.json b/static/locales/ca.client.json
new file mode 100644
index 00000000..0967ef42
--- /dev/null
+++ b/static/locales/ca.client.json
@@ -0,0 +1 @@
+{}

From 54502280de6d3e2856e943808d09a2406b45c44b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= <jordigh@octave.org>
Date: Wed, 14 Aug 2024 16:14:13 -0400
Subject: [PATCH 140/145] (core) AdminPanel: hide the enterprise toggle in core
 and grist-ee images

Summary:
In the pure OSS `grist-oss` image, the `ActivationPage` module from
stubs is used, as the `ext` code is completely missing. We can easily
just always return `false` here.

In the case when the `ext` directory exists, this may mean we're in
the standard `grist` image or the `grist-ee` image. The latter is
distinguished by having `GRIST_FORCE_ENABLE_ENTERPRISE` so we check if
that's on, and hide the toggle accordingly if so.

Test Plan:
Use these changes to build the three Docker images
(`grist-oss`, `grist`, and `grist-ee`) and verify that only `grist`
shows the toggle.

Reviewers: jarek

Reviewed By: jarek

Subscribers: jarek

Differential Revision: https://phab.getgrist.com/D4321
---
 app/client/ui/AdminPanel.ts           | 22 +++++++++++++++-------
 app/common/gristUrls.ts               |  3 +++
 app/server/lib/sendAppPage.ts         |  1 +
 stubs/app/client/ui/ActivationPage.ts |  5 +++++
 4 files changed, 24 insertions(+), 7 deletions(-)

diff --git a/app/client/ui/AdminPanel.ts b/app/client/ui/AdminPanel.ts
index ae1fa8ef..e2cc38bf 100644
--- a/app/client/ui/AdminPanel.ts
+++ b/app/client/ui/AdminPanel.ts
@@ -26,6 +26,7 @@ import {Computed, Disposable, dom, IDisposable,
         IDisposableOwner, MultiHolder, Observable, styled} from 'grainjs';
 import {AdminSection, AdminSectionItem, HidableToggle} from 'app/client/ui/AdminPanelCss';
 import {getAdminPanelName} from 'app/client/ui/AdminPanelName';
+import {showEnterpriseToggle} from 'app/client/ui/ActivationPage';
 
 const t = makeT('AdminPanel');
 
@@ -158,13 +159,7 @@ Please log in as an administrator.`)),
           description: t('Current version of Grist'),
           value: cssValueLabel(`Version ${version.version}`),
         }),
-        dom.create(AdminSectionItem, {
-          id: 'enterprise',
-          name: t('Enterprise'),
-          description: t('Enable Grist Enterprise'),
-          value: dom.create(HidableToggle, this._toggleEnterprise.getEnterpriseToggleObservable()),
-          expandedContent: this._toggleEnterprise.buildEnterpriseSection(),
-        }),
+        this._maybeAddEnterpriseToggle(),
         this._buildUpdates(owner),
       ]),
       dom.create(AdminSection, t('Self Checks'), [
@@ -186,6 +181,19 @@ Please log in as an administrator.`)),
     ];
   }
 
+  private _maybeAddEnterpriseToggle() {
+    if (!showEnterpriseToggle()) {
+      return null;
+    }
+    return dom.create(AdminSectionItem, {
+      id: 'enterprise',
+      name: t('Enterprise'),
+      description: t('Enable Grist Enterprise'),
+      value: dom.create(HidableToggle, this._toggleEnterprise.getEnterpriseToggleObservable()),
+      expandedContent: this._toggleEnterprise.buildEnterpriseSection(),
+    });
+  }
+
   private _buildSandboxingDisplay(owner: IDisposableOwner) {
     return dom.domComputed(
       use => {
diff --git a/app/common/gristUrls.ts b/app/common/gristUrls.ts
index e8410e1d..6cbe7d77 100644
--- a/app/common/gristUrls.ts
+++ b/app/common/gristUrls.ts
@@ -810,6 +810,9 @@ export interface GristLoadConfig {
   // The Grist deployment type (e.g. core, enterprise).
   deploymentType?: GristDeploymentType;
 
+  // Force enterprise deployment? For backwards compatibility with grist-ee Docker image
+  forceEnableEnterprise?: boolean;
+
   // The org containing public templates and tutorials.
   templateOrg?: string|null;
 
diff --git a/app/server/lib/sendAppPage.ts b/app/server/lib/sendAppPage.ts
index 653028a7..38f3dcb5 100644
--- a/app/server/lib/sendAppPage.ts
+++ b/app/server/lib/sendAppPage.ts
@@ -96,6 +96,7 @@ export function makeGristConfig(options: MakeGristConfigOptions): GristLoadConfi
     userLocale: (req as RequestWithLogin | undefined)?.user?.options?.locale,
     telemetry: server?.getTelemetry().getTelemetryConfig(req as RequestWithLogin | undefined),
     deploymentType: server?.getDeploymentType(),
+    forceEnableEnterprise: isAffirmative(process.env.GRIST_FORCE_ENABLE_ENTERPRISE),
     templateOrg: getTemplateOrg(),
     onboardingTutorialDocId: getOnboardingTutorialDocId(),
     canCloseAccount: isAffirmative(process.env.GRIST_ACCOUNT_CLOSE),
diff --git a/stubs/app/client/ui/ActivationPage.ts b/stubs/app/client/ui/ActivationPage.ts
index 99b90e57..cc65c547 100644
--- a/stubs/app/client/ui/ActivationPage.ts
+++ b/stubs/app/client/ui/ActivationPage.ts
@@ -5,3 +5,8 @@ import {
 export function getActivationPage(): IActivationPageCreator {
   return DefaultActivationPage;
 }
+
+export function showEnterpriseToggle() {
+  // To be changed by enterprise module
+  return false;
+}

From be925e6ff3213ae6ef213645b74cd99ea348a533 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= <jordigh@octave.org>
Date: Mon, 19 Aug 2024 17:38:30 -0400
Subject: [PATCH 141/145] build: update grist-ee version

---
 buildtools/.grist-ee-version | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/buildtools/.grist-ee-version b/buildtools/.grist-ee-version
index c81aa44a..e3e18070 100644
--- a/buildtools/.grist-ee-version
+++ b/buildtools/.grist-ee-version
@@ -1 +1 @@
-0.9.7
+0.9.8

From 7fcd740f111ba21a788b0564e068ba03b654605e Mon Sep 17 00:00:00 2001
From: Xavi Montero <xmontero@dsitelecom.com>
Date: Mon, 19 Aug 2024 17:15:27 +0000
Subject: [PATCH 142/145] Translated using Weblate (Catalan)

Currently translated at 0.9% (13 of 1378 strings)

Translation: Grist/client
Translate-URL: https://hosted.weblate.org/projects/grist/client/ca/
---
 static/locales/ca.client.json | 20 +++++++++++++++++++-
 1 file changed, 19 insertions(+), 1 deletion(-)

diff --git a/static/locales/ca.client.json b/static/locales/ca.client.json
index 0967ef42..62b52dfa 100644
--- a/static/locales/ca.client.json
+++ b/static/locales/ca.client.json
@@ -1 +1,19 @@
-{}
+{
+    "ACUserManager": {
+        "Invite new member": "Convidar nou membre",
+        "Enter email address": "Posa l'adreça de correu electrònic",
+        "We'll email an invite to {{email}}": "Enviarem una invitació per correu electrònic a {{email}}"
+    },
+    "AccessRules": {
+        "Add Default Rule": "Afegir regla per defecte",
+        "Add Table Rules": "Afegir regles de Taula",
+        "Add User Attributes": "Afegir atributs d'usuari",
+        "Allow everyone to view Access Rules.": "Permetre a tothom veure les Regles d'Accés.",
+        "Attribute name": "Nom de l'Atribut",
+        "Attribute to Look Up": "Atribut a buscar",
+        "Checking...": "Comprovant…",
+        "Condition": "Condició",
+        "Add Column Rule": "Afegir regla de columna",
+        "Allow everyone to copy the entire document, or view it in full in fiddle mode.\nUseful for examples and templates, but not for sensitive data.": "Permetre a tothom copiar el document complet, o veure'l en mode completament obert.\nÚtil per a exemples i plantilles, però no per a dades sensibles."
+    }
+}

From 5e1709b206fac39110e1fe1e4479cb6ca88d2f67 Mon Sep 17 00:00:00 2001
From: Dmitry <dsagal+git@gmail.com>
Date: Fri, 23 Aug 2024 14:09:31 -0400
Subject: [PATCH 143/145] Update CONTRIBUTING.md to link to issue templates
 (#1148)

Point to the new issue-creation page, now that we have templates for new issues.
---
 CONTRIBUTING.md | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 992ba865..ac31b00c 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -4,5 +4,5 @@ You are eager to contribute to Grist? That's awesome! See below some contributio
 - [translate](/documentation/translations.md)
 - [write tutorials and user documentation](https://github.com/gristlabs/grist-help?tab=readme-ov-file#grist-help-center)
 - [develop](/documentation/develop.md)
-- [report issues or suggest enhancement](https://github.com/gristlabs/grist-core/issues/new)
+- [report issues or suggest enhancement](https://github.com/gristlabs/grist-core/issues/new/choose)
 

From 8b48d1bc338671df708a3cc03d008771a38942af Mon Sep 17 00:00:00 2001
From: Dmitry <dsagal+git@gmail.com>
Date: Fri, 23 Aug 2024 17:50:08 -0400
Subject: [PATCH 144/145] Make "grist-local-testing" docker-compose example
 work out of the box (#1165)

* Maintain ./persist subdirectory using a dummy .gitkeep file
* Have PERSIST_DIR default to it
* Update README with more detail how to run and info about PERSIST_DIR
---
 docker-compose-examples/grist-local-testing/README.md    | 9 ++++++++-
 .../grist-local-testing/docker-compose.yml               | 2 +-
 .../grist-local-testing/persist/.gitkeep                 | 0
 3 files changed, 9 insertions(+), 2 deletions(-)
 create mode 100644 docker-compose-examples/grist-local-testing/persist/.gitkeep

diff --git a/docker-compose-examples/grist-local-testing/README.md b/docker-compose-examples/grist-local-testing/README.md
index cd610cfd..ad45379b 100644
--- a/docker-compose-examples/grist-local-testing/README.md
+++ b/docker-compose-examples/grist-local-testing/README.md
@@ -9,4 +9,11 @@ See https://support.getgrist.com/self-managed for more information.
 
 ## How to run this example
 
-This example can be run with `docker compose up`.
\ No newline at end of file
+To run this example, change to the directory containing this example, and run:
+```sh
+docker compose up
+```
+Then you should be able to visit your local Grist instance at <http://localhost:8484>.
+
+This will start an instance that stores its documents and files in the `persist/` subdirectory.
+You can change this location using the `PERSIST_DIR` environment variable.
diff --git a/docker-compose-examples/grist-local-testing/docker-compose.yml b/docker-compose-examples/grist-local-testing/docker-compose.yml
index 028d4e0f..5ccf7399 100644
--- a/docker-compose-examples/grist-local-testing/docker-compose.yml
+++ b/docker-compose-examples/grist-local-testing/docker-compose.yml
@@ -3,6 +3,6 @@ services:
     image: gristlabs/grist:latest
     volumes:
       # Where to store persistent data, such as documents.
-      - ${PERSIST_DIR}/grist:/persist
+      - ${PERSIST_DIR:-./persist}/grist:/persist
     ports:
       - 8484:8484
diff --git a/docker-compose-examples/grist-local-testing/persist/.gitkeep b/docker-compose-examples/grist-local-testing/persist/.gitkeep
new file mode 100644
index 00000000..e69de29b

From 76fcfd733e94b70104f521a58164c545d715b177 Mon Sep 17 00:00:00 2001
From: Florent <florent.git@zeteo.me>
Date: Tue, 27 Aug 2024 12:38:35 +0200
Subject: [PATCH 145/145] Small: Log requests body (#913)

Add body in log requests.

GRIST_LOG_SKIP_HTTP is a badly named environment variable and its
expected values are confusing (to log the requests, you actually have to
set its value to "", and setting to "false" actually is equivalent to
setting to "true").

We deprecate this env variable in favor of GRIST_LOG_HTTP which is more
convenient and understandable:
 - by default, its undefined, so nothing is logged;
 - to enable the logs, you just have to set GRIST_LOG_HTTP=true

Also this commit removes the default value for GRIST_LOG_SKIP_HTTP,
because we don't have to set it to "true" to actually disable the
requests logging thanks to GRIST_LOG_HTTP. FlexServer now handles
the historical behavior for this deprecated variable.

---------

Co-authored-by: Jonathan Perret <j-github@jonathanperret.net>
---
 README.md                    |  2 ++
 app/server/lib/FlexServer.ts | 42 +++++++++++++++++++++++++++++++++---
 stubs/app/server/server.ts   |  1 -
 test/test_under_docker.sh    |  5 ++++-
 4 files changed, 45 insertions(+), 5 deletions(-)

diff --git a/README.md b/README.md
index 401baf37..d2e0a9e4 100644
--- a/README.md
+++ b/README.md
@@ -312,6 +312,8 @@ Grist can be configured in many ways. Here are the main environment variables it
 | GRIST_UI_FEATURES                  | comma-separated list of UI features to enable. Allowed names of parts: `helpCenter,billing,templates,createSite,multiSite,multiAccounts,sendToDrive,tutorials,supportGrist`. If a part also exists in GRIST_HIDE_UI_ELEMENTS, it won't be enabled.                                                                                                            |
 | GRIST_UNTRUSTED_PORT               | if set, plugins will be served from the given port. This is an alternative to setting APP_UNTRUSTED_URL.                                                                                                                                                                                                                                                      |
 | GRIST_WIDGET_LIST_URL              | a url pointing to a widget manifest, by default `https://github.com/gristlabs/grist-widget/releases/download/latest/manifest.json` is used                                                                                                                                                                                                                    |
+| GRIST_LOG_HTTP                     | When set to `true`, log HTTP requests and responses information. Defaults to `false`.                                                                                                                                                                                                                                                                         |
+| GRIST_LOG_HTTP_BODY                | When this variable and `GRIST_LOG_HTTP` are set to `true` , log the body along with the HTTP requests. :warning: Be aware it may leak confidential information in the logs.:warning: Defaults to `false`.                                                                                                                                                   |
 | COOKIE_MAX_AGE                     | session cookie max age, defaults to 90 days; can be set to "none" to make it a session cookie                                                                                                                                                                                                                                                                 |
 | HOME_PORT                          | port number to listen on for REST API server; if set to "share", add API endpoints to regular grist port.                                                                                                                                                                                                                                                     |
 | PORT                               | port number to listen on for Grist server                                                                                                                                                                                                                                                                                                                     |
diff --git a/app/server/lib/FlexServer.ts b/app/server/lib/FlexServer.ts
index 37070d98..5391b5f2 100644
--- a/app/server/lib/FlexServer.ts
+++ b/app/server/lib/FlexServer.ts
@@ -442,24 +442,33 @@ export class FlexServer implements GristServer {
 
   public addLogging() {
     if (this._check('logging')) { return; }
-    if (process.env.GRIST_LOG_SKIP_HTTP) { return; }
+    if (!this._httpLoggingEnabled()) { return; }
     // Add a timestamp token that matches exactly the formatting of non-morgan logs.
     morganLogger.token('logTime', (req: Request) => log.timestamp());
     // Add an optional gristInfo token that can replace the url, if the url is sensitive.
     morganLogger.token('gristInfo', (req: RequestWithGristInfo) =>
                        req.gristInfo || req.originalUrl || req.url);
     morganLogger.token('host', (req: express.Request) => req.get('host'));
-    const msg = ':logTime :host :method :gristInfo :status :response-time ms - :res[content-length]';
+    morganLogger.token('body', (req: express.Request) =>
+      req.is('application/json') ? JSON.stringify(req.body) : undefined
+    );
+
+    // For debugging, be careful not to enable logging in production (may log sensitive data)
+    const shouldLogBody = isAffirmative(process.env.GRIST_LOG_HTTP_BODY);
+
+    const msg = `:logTime :host :method :gristInfo ${shouldLogBody ? ':body ' : ''}` +
+      ":status :response-time ms - :res[content-length]";
     // In hosted Grist, render json so logs retain more organization.
     function outputJson(tokens: any, req: any, res: any) {
       return JSON.stringify({
         timestamp: tokens.logTime(req, res),
+        host: tokens.host(req, res),
         method: tokens.method(req, res),
         path: tokens.gristInfo(req, res),
+        ...(shouldLogBody ? { body: tokens.body(req, res) } : {}),
         status: tokens.status(req, res),
         timeMs: parseFloat(tokens['response-time'](req, res)) || undefined,
         contentLength: parseInt(tokens.res(req, res, 'content-length'), 10) || undefined,
-        host: tokens.host(req, res),
         altSessionId: req.altSessionId,
       });
     }
@@ -2489,6 +2498,33 @@ export class FlexServer implements GristServer {
       [];
     return [...pluggedMiddleware, sessionClearMiddleware];
   }
+
+  /**
+   * Returns true if GRIST_LOG_HTTP="true" (or any truthy value).
+   * Returns true if GRIST_LOG_SKIP_HTTP="" (empty string).
+   * Returns false otherwise.
+   *
+   * Also displays a deprecation warning if GRIST_LOG_SKIP_HTTP is set to any value ("", "true", whatever...),
+   * and throws an exception if GRIST_LOG_SKIP_HTTP and GRIST_LOG_HTTP are both set to make the server crash.
+   */
+  private _httpLoggingEnabled(): boolean {
+    const deprecatedOptionEnablesLog = process.env.GRIST_LOG_SKIP_HTTP === '';
+    const isGristLogHttpEnabled = isAffirmative(process.env.GRIST_LOG_HTTP);
+
+    if (process.env.GRIST_LOG_HTTP !== undefined && process.env.GRIST_LOG_SKIP_HTTP !== undefined) {
+      throw new Error('Both GRIST_LOG_HTTP and GRIST_LOG_SKIP_HTTP are set. ' +
+        'Please remove GRIST_LOG_SKIP_HTTP and set GRIST_LOG_HTTP to the value you actually want.');
+    }
+
+    if (process.env.GRIST_LOG_SKIP_HTTP !== undefined) {
+      const expectedGristLogHttpVal = deprecatedOptionEnablesLog ? "true" : "false";
+
+      log.warn(`Setting env variable GRIST_LOG_SKIP_HTTP="${process.env.GRIST_LOG_SKIP_HTTP}" `
+        + `is deprecated in favor of GRIST_LOG_HTTP="${expectedGristLogHttpVal}"`);
+    }
+
+    return isGristLogHttpEnabled || deprecatedOptionEnablesLog;
+  }
 }
 
 /**
diff --git a/stubs/app/server/server.ts b/stubs/app/server/server.ts
index 6fbffdf5..c7ccc7be 100644
--- a/stubs/app/server/server.ts
+++ b/stubs/app/server/server.ts
@@ -15,7 +15,6 @@ const debugging = isAffirmative(process.env.DEBUG) || isAffirmative(process.env.
 if (!debugging) {
   // Be a lot less noisy by default.
   setDefaultEnv('GRIST_LOG_LEVEL', 'error');
-  setDefaultEnv('GRIST_LOG_SKIP_HTTP', 'true');
 }
 
 // Use a distinct cookie.  Bump version to 2.
diff --git a/test/test_under_docker.sh b/test/test_under_docker.sh
index fd75fb10..6663691d 100755
--- a/test/test_under_docker.sh
+++ b/test/test_under_docker.sh
@@ -31,6 +31,8 @@ cleanup() {
 GRIST_LOG_LEVEL="error"
 if [[ "${DEBUG:-}" == 1 ]]; then
   GRIST_LOG_LEVEL=""
+  GRIST_LOG_HTTP="true"
+  GRIST_LOG_HTTP_BODY="true"
 fi
 
 docker run --name $DOCKER_CONTAINER --rm \
@@ -39,7 +41,8 @@ docker run --name $DOCKER_CONTAINER --rm \
   --env GRIST_SESSION_COOKIE=grist_test_cookie \
   --env GRIST_TEST_LOGIN=1 \
   --env GRIST_LOG_LEVEL=$GRIST_LOG_LEVEL \
-  --env GRIST_LOG_SKIP_HTTP=${DEBUG:-false} \
+  --env GRIST_LOG_HTTP=${GRIST_LOG_HTTP:-false} \
+  --env GRIST_LOG_HTTP_BODY=${GRIST_LOG_HTTP_BODY:-false} \
   --env TEST_SUPPORT_API_KEY=api_key_for_support \
   --env GRIST_TEMPLATE_ORG=templates \
   ${TEST_IMAGE:-gristlabs/grist} &